consume only artifacts the runtime produced
tile_manifest_path arrives in the detection and segmentation request and was read straight off disk, and a manifest entry may name an absolute tile path. That makes an API field an unbounded reference to the host filesystem, and it contradicts the rule the persistence model rests on: only a governed, runtime-produced artifact may be consumed, and a file outside the storage root is not one. Both the manifest and every tile it names now resolve under STORAGE_ROOT. Resolution happens before the comparison, so ".." cannot climb out and a sibling that merely shares a name prefix does not pass. GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS opts out for provisioning workflows that stage tiles before ingest. The check honours the Settings the caller is operating under rather than the process-wide ones, because every analysis path already threads its own. The affected tests write manifests into tmp_path, so they now declare tmp_path as the storage root — which is what a deployment does, and makes the fixtures more honest than they were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
from app.services.model_validation_scope_service import ModelValidationScopeService
|
||||
from app.services.qa_service import QaService
|
||||
from app.services.storage_service import StorageService
|
||||
from app.services.quality_service import QualityService
|
||||
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
||||
from app.services.temporal_compatibility_service import TemporalCompatibilityService
|
||||
@@ -450,7 +451,7 @@ class DetectionService:
|
||||
|
||||
coverage = None
|
||||
if manifest_path:
|
||||
manifest = DetectionService._load_tile_manifest(manifest_path, resolved_settings.yolo_max_tiles)
|
||||
manifest = DetectionService._load_tile_manifest(manifest_path, resolved_settings.yolo_max_tiles, resolved_settings)
|
||||
coverage = DetectionQaService.build_tile_coverage(
|
||||
manifest,
|
||||
manifest_path=manifest_path,
|
||||
@@ -942,7 +943,7 @@ class DetectionService:
|
||||
settings: Settings,
|
||||
yolo_adapter_class: Type[YoloDetectionAdapter],
|
||||
) -> tuple[list[Detection], dict[str, Any]]:
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles)
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings)
|
||||
model_path = Path(settings.yolo_model_path or "").expanduser()
|
||||
runtime_model_provenance = RuntimeModelProvenanceService.validate_for_production_runtime(
|
||||
db=db,
|
||||
@@ -965,7 +966,7 @@ class DetectionService:
|
||||
raster_bounds = DetectionService._bounds_to_epsg4326(manifest.get("bounds"), manifest_crs)
|
||||
tiles = list(manifest["tiles"])
|
||||
tile_paths = [
|
||||
DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser()) for tile in tiles
|
||||
DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser(), settings) for tile in tiles
|
||||
]
|
||||
# Batched so the GPU is not idle between tiles; each tile keeps its own
|
||||
# transform for georeferencing, so results stay per tile and in order.
|
||||
@@ -1248,14 +1249,18 @@ class DetectionService:
|
||||
return 1.5 * max((right - left) / width, (top - bottom) / height)
|
||||
|
||||
@staticmethod
|
||||
def _load_tile_manifest(tile_manifest_path: str | None, max_tiles: int) -> dict[str, Any]:
|
||||
def _load_tile_manifest(tile_manifest_path: str | None, max_tiles: int, settings: Settings | None = None) -> dict[str, Any]:
|
||||
if not tile_manifest_path:
|
||||
raise AppError(
|
||||
code="DETECTION_TILE_MANIFEST_REQUIRED",
|
||||
message="Configured YOLO inference requires an existing raster tile manifest path",
|
||||
status_code=400,
|
||||
)
|
||||
manifest_path = Path(tile_manifest_path).expanduser()
|
||||
# The path arrives in the request, so it must name a governed artifact
|
||||
# rather than an arbitrary file on the host.
|
||||
manifest_path = StorageService.assert_within_storage_root(
|
||||
tile_manifest_path, label="tile manifest", settings=settings
|
||||
)
|
||||
if not manifest_path.exists() or not manifest_path.is_file():
|
||||
raise AppError(
|
||||
code="DETECTION_TILE_MANIFEST_NOT_FOUND",
|
||||
@@ -1280,13 +1285,16 @@ class DetectionService:
|
||||
return manifest
|
||||
|
||||
@staticmethod
|
||||
def _resolve_tile_path(tile: dict[str, Any], manifest_path: Path) -> Path:
|
||||
def _resolve_tile_path(tile: dict[str, Any], manifest_path: Path, settings: Settings | None = None) -> Path:
|
||||
raw_path = tile.get("path")
|
||||
if not isinstance(raw_path, str) or not raw_path:
|
||||
raise AppError(code="DETECTION_TILE_MANIFEST_INVALID", message="Tile manifest entries require a path", status_code=422)
|
||||
tile_path = Path(raw_path).expanduser()
|
||||
if not tile_path.is_absolute():
|
||||
tile_path = manifest_path.parent / tile_path
|
||||
# A manifest entry may name an absolute path; it is still only allowed
|
||||
# to point at a tile the runtime itself produced.
|
||||
tile_path = StorageService.assert_within_storage_root(tile_path, label="raster tile", settings=settings)
|
||||
if not tile_path.exists() or not tile_path.is_file():
|
||||
raise AppError(
|
||||
code="DETECTION_TILE_NOT_FOUND",
|
||||
|
||||
@@ -380,7 +380,10 @@ class SegmentationService:
|
||||
manifest_path = DetectionQaService.tile_manifest_path(run_parameters)
|
||||
coverage = None
|
||||
if manifest_path:
|
||||
manifest = DetectionService._load_tile_manifest(manifest_path, get_settings().yolo_max_tiles)
|
||||
settings_for_qa = get_settings()
|
||||
manifest = DetectionService._load_tile_manifest(
|
||||
manifest_path, settings_for_qa.yolo_max_tiles, settings_for_qa
|
||||
)
|
||||
coverage = DetectionQaService.build_tile_coverage(
|
||||
manifest,
|
||||
manifest_path=manifest_path,
|
||||
@@ -700,7 +703,7 @@ class SegmentationService:
|
||||
yolo_seg_adapter_class: type[YoloSegmentationAdapter],
|
||||
sam_adapter_class: type[SamSegmentationAdapter],
|
||||
) -> tuple[list[Segmentation], dict[str, Any]]:
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles)
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings)
|
||||
if model_name == settings.sam_model_id:
|
||||
model_path = Path(settings.sam_model_path or "").expanduser()
|
||||
allowed_frameworks = ("ultralytics/sam", "sam", "ultralytics", "pytorch")
|
||||
@@ -728,7 +731,7 @@ class SegmentationService:
|
||||
manifest_crs = DetectionService._require_manifest_crs(manifest)
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for tile in manifest["tiles"]:
|
||||
tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser())
|
||||
tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser(), settings)
|
||||
for raw in adapter.predict_tile(model, tile_path, confidence_threshold):
|
||||
model_class_name = str(raw.get("class_name") or "").strip()
|
||||
class_name = DetectionService._canonical_class_name(model_class_name)
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
class StorageService:
|
||||
@@ -13,6 +14,53 @@ class StorageService:
|
||||
def _base_dir() -> Path:
|
||||
return Path(get_settings().storage_root).resolve()
|
||||
|
||||
@staticmethod
|
||||
def assert_within_storage_root(
|
||||
path: str | Path,
|
||||
*,
|
||||
label: str = "artifact",
|
||||
settings: Any = None,
|
||||
) -> Path:
|
||||
"""Resolve a path and refuse anything outside the configured storage root.
|
||||
|
||||
``tile_manifest_path`` arrives in the analysis request and a manifest
|
||||
entry may name an absolute tile path, so without this an API field is an
|
||||
unbounded reference to the host filesystem. It is also the persistence
|
||||
rule the product already states: only a governed, runtime-produced
|
||||
artifact may be consumed, and a file outside the root is not one.
|
||||
|
||||
Resolution happens before the comparison, so ``..`` cannot climb out and
|
||||
a sibling that merely shares a name prefix does not pass.
|
||||
"""
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = settings or get_settings()
|
||||
raw = str(path or "").strip()
|
||||
if not raw:
|
||||
raise AppError(
|
||||
code="STORAGE_PATH_OUTSIDE_ROOT",
|
||||
message=f"A {label} path is required.",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
resolved = Path(raw).expanduser().resolve()
|
||||
if getattr(settings, "allow_external_artifact_paths", False):
|
||||
return resolved
|
||||
|
||||
root = Path(settings.storage_root).resolve()
|
||||
if resolved != root and root not in resolved.parents:
|
||||
raise AppError(
|
||||
code="STORAGE_PATH_OUTSIDE_ROOT",
|
||||
message=(
|
||||
f"The {label} path lies outside the configured storage root and is therefore not a "
|
||||
"governed artifact."
|
||||
),
|
||||
details={"storage_root": str(root)},
|
||||
status_code=400,
|
||||
)
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def normalize_dataset_type(dataset_type: str) -> str:
|
||||
normalized = dataset_type.strip().lower()
|
||||
|
||||
@@ -149,8 +149,11 @@ class YoloPreflightService:
|
||||
return result
|
||||
|
||||
try:
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, resolved_settings.yolo_max_tiles)
|
||||
tile_paths = [DetectionService._resolve_tile_path(tile, Path(tile_manifest_path).expanduser()) for tile in manifest["tiles"]]
|
||||
manifest = DetectionService._load_tile_manifest(tile_manifest_path, resolved_settings.yolo_max_tiles, resolved_settings)
|
||||
tile_paths = [
|
||||
DetectionService._resolve_tile_path(tile, Path(tile_manifest_path).expanduser(), resolved_settings)
|
||||
for tile in manifest["tiles"]
|
||||
]
|
||||
except AppError as exc:
|
||||
result["status"] = "manifest_invalid"
|
||||
result["message"] = exc.message
|
||||
|
||||
Reference in New Issue
Block a user