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:
Jens
2026-08-22 16:16:25 +02:00
co-authored by Claude Opus 5
parent 16dedeb670
commit e2f586c029
14 changed files with 243 additions and 21 deletions
+5
View File
@@ -49,6 +49,11 @@ class Settings(BaseSettings):
validation_alias="DATABASE_URL",
)
storage_root: str = Field(default="./storage", validation_alias="STORAGE_ROOT")
# Analysis consumes only artifacts under storage_root. Provisioning
# workflows that stage tiles elsewhere before ingest can opt out.
allow_external_artifact_paths: bool = Field(
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
)
max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_MB")
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
orthophoto_wms_url: str = Field(
+14 -6
View File
@@ -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",
+6 -3
View File
@@ -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)
+48
View File
@@ -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