US07-02: Harden API Authorization and Path Boundaries
This commit is contained in:
@@ -25,6 +25,7 @@ from typing import Protocol
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from photo_pipeline import path_policy
|
||||
from photo_pipeline.integrations import exiftool
|
||||
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
|
||||
from photo_pipeline.services import hashing
|
||||
@@ -59,9 +60,16 @@ def _now() -> datetime:
|
||||
|
||||
|
||||
class AnalysisService:
|
||||
def __init__(self, session_factory: sessionmaker, *, provider: VisionProvider | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: sessionmaker,
|
||||
*,
|
||||
provider: VisionProvider | None = None,
|
||||
library_roots: tuple = (),
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._provider = provider
|
||||
self._roots = tuple(library_roots)
|
||||
|
||||
def _sfw_asset_ids(self, session) -> set[str]:
|
||||
"""Asset ids whose latest safety decision is ``sfw`` — the ONLY assets that
|
||||
@@ -136,6 +144,18 @@ class AnalysisService:
|
||||
if not path:
|
||||
skipped += 1
|
||||
continue
|
||||
# Second gate, at the moment of use: the database says where the file
|
||||
# was, the filesystem decides what that name means now. A link swapped
|
||||
# under an asset after the scan would otherwise send bytes from outside
|
||||
# the library — the one place that leaves this machine (US07-02).
|
||||
try:
|
||||
path = str(path_policy.resolve_in_roots(self._roots, path))
|
||||
except path_policy.PathPolicyError as error:
|
||||
self._store(
|
||||
asset_id, status="error", result=None, error=str(error), tokens=0, raw=""
|
||||
)
|
||||
errors += 1
|
||||
continue
|
||||
try:
|
||||
result = provider.analyze(path, album_hint=_album_hint(path))
|
||||
except Exception as error: # provider/validation failure is per-asset
|
||||
|
||||
@@ -129,7 +129,10 @@ class ThumbnailService:
|
||||
raise ThumbnailUnavailable(f"asset {asset_id} has no readable file")
|
||||
source = str(source)
|
||||
if source == asset.current_path:
|
||||
self._validate_path(source) # archive roots lie outside the library
|
||||
# Render the *resolved* path the check approved: revalidating and then
|
||||
# reopening the original name would let a symlink swapped in between
|
||||
# the two steps decide which bytes are served (US07-02).
|
||||
source = str(self._validate_path(source)) # archive roots lie outside
|
||||
|
||||
# Rendering happens outside the DB session (no transaction held during I/O).
|
||||
try:
|
||||
@@ -195,20 +198,16 @@ class ThumbnailService:
|
||||
session.commit()
|
||||
|
||||
# ── path safety ──────────────────────────────────────────────────────────
|
||||
def _validate_path(self, current_path: str) -> None:
|
||||
path = Path(current_path)
|
||||
if path_policy.is_excluded(path):
|
||||
raise PathNotAllowed(f"excluded path: {current_path}")
|
||||
roots = self._config.library_roots
|
||||
if not roots:
|
||||
return
|
||||
for root in roots:
|
||||
try:
|
||||
path_policy.resolve_within(Path(root), path)
|
||||
return
|
||||
except path_policy.PathPolicyError:
|
||||
continue
|
||||
raise PathNotAllowed(f"path outside configured roots: {current_path}")
|
||||
def _validate_path(self, current_path: str) -> Path:
|
||||
"""The resolved path to read, or ``PathNotAllowed``.
|
||||
|
||||
The message names no path: a refusal is returned to the browser, and where
|
||||
the library lives is not the caller's business (US07-02).
|
||||
"""
|
||||
try:
|
||||
return path_policy.resolve_in_roots(self._config.library_roots, current_path)
|
||||
except path_policy.PathPolicyError as error:
|
||||
raise PathNotAllowed(str(error)) from None
|
||||
|
||||
# ── cache key + rendering ──────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user