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
+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()