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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user