"""URI-aware authorization for nginx-served static media. The `/auth` endpoint (used as nginx `auth_request` target) calls into this module to classify the requested URI from the `X-Original-URL` header and, for camera-scoped resources, decide whether the current role may access them. Without this, `auth_request` only verifies the JWT — every authenticated user could read clips, recordings, and exports for *any* camera, bypassing the per-camera authorization the regular API enforces via `require_camera_access`. """ from __future__ import annotations import logging import os from enum import Enum from urllib.parse import unquote, urlparse from peewee import DoesNotExist from frigate.config import FrigateConfig from frigate.const import EXPORT_DIR from frigate.models import Export, User logger = logging.getLogger(__name__) class MediaAuthResolution(str, Enum): """Classification of an `X-Original-URL` path for media-auth purposes.""" CAMERA = "camera" ADMIN_ONLY = "admin_only" LISTING_MULTI_CAMERA = "listing_multi_camera" LISTING_NEUTRAL = "listing_neutral" # Under a recognized media root (/clips, /recordings, /exports) but # unclassifiable (unknown subtree, no matching DB row, DB error). # Restricted users are denied; admins/full-access roles are allowed # (nginx will likely return 404 if the file genuinely doesn't exist). UNRESOLVED_MEDIA = "unresolved_media" # Not a media URI at all (e.g. /api/events, /login). UNKNOWN = "unknown" def extract_path(original_url: str | None) -> str | None: """Return the decoded path component of nginx's `X-Original-URL` header. nginx forwards the *raw* request URI (with `..` segments intact) via `$request_uri`. nginx normalizes the path before serving the file, so a request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4` would (1) parse as the allowed camera in our auth check, (2) be served as the forbidden camera by nginx. To close the bypass we reject any URI whose path contains `.` or `..` segments outright. """ if not original_url: return None parsed = urlparse(original_url) raw_path = parsed.path or original_url decoded = unquote(raw_path) if not decoded: return None if not decoded.startswith("/"): decoded = "/" + decoded segments = decoded.split("/") if ".." in segments or "." in segments: return None return decoded def resolve_media_uri( uri: str, frigate_config: FrigateConfig | None = None ) -> tuple[MediaAuthResolution, str | None]: """Classify a URI and return the owning camera if applicable. `frigate_config` is used to disambiguate clip/review filenames whose camera name contains hyphens by matching against the longest configured camera-name prefix. """ if not uri: return MediaAuthResolution.UNKNOWN, None parts = [p for p in uri.split("/") if p] if not parts: return MediaAuthResolution.UNKNOWN, None root = parts[0] if root == "recordings": return _resolve_recording(parts) if root == "clips": return _resolve_clip(parts, frigate_config) if root == "exports": return _resolve_export(parts) return MediaAuthResolution.UNKNOWN, None def _resolve_recording( parts: list[str], ) -> tuple[MediaAuthResolution, str | None]: # /recordings → neutral # /recordings/{date} → neutral # /recordings/{date}/{hour} → multi-camera listing # /recordings/{date}/{hour}/{cam}/... → camera if len(parts) <= 2: return MediaAuthResolution.LISTING_NEUTRAL, None if len(parts) == 3: return MediaAuthResolution.LISTING_MULTI_CAMERA, None return MediaAuthResolution.CAMERA, parts[3] def _resolve_clip( parts: list[str], frigate_config: FrigateConfig | None ) -> tuple[MediaAuthResolution, str | None]: # /clips → multi-camera listing # /clips/thumbs/{cam}/... → camera # /clips/previews/{cam}/... → camera # /clips/review/thumb-{cam}-{review_id}.webp → camera (parsed) # /clips/faces/... → admin-only # /clips/genai-requests/... → admin-only # /clips/preview_restart_cache/... → admin-only # /clips/{model}/train|dataset/... → admin-only # /clips/{cam}-{event_id}[-clean].{ext} → camera (parsed) # other /clips/{subdir}/... → unresolved (deny restricted) if len(parts) == 1: return MediaAuthResolution.LISTING_MULTI_CAMERA, None second = parts[1] if second in ("thumbs", "previews"): if len(parts) == 2: return MediaAuthResolution.LISTING_MULTI_CAMERA, None return MediaAuthResolution.CAMERA, parts[2] if second == "review": if len(parts) == 2: return MediaAuthResolution.LISTING_MULTI_CAMERA, None camera = _camera_from_thumb_filename(parts[2], frigate_config) if camera: return MediaAuthResolution.CAMERA, camera return MediaAuthResolution.UNRESOLVED_MEDIA, None if second in ("faces", "genai-requests", "preview_restart_cache"): return MediaAuthResolution.ADMIN_ONLY, None if len(parts) >= 3 and parts[2] in ("train", "dataset"): return MediaAuthResolution.ADMIN_ONLY, None if len(parts) == 2: camera = _camera_from_clip_filename(second, frigate_config) if camera: return MediaAuthResolution.CAMERA, camera return MediaAuthResolution.UNRESOLVED_MEDIA, None return MediaAuthResolution.UNRESOLVED_MEDIA, None def _longest_prefix_camera( stem: str, frigate_config: FrigateConfig | None ) -> str | None: if frigate_config is None: return None for cam in sorted(frigate_config.cameras.keys(), key=len, reverse=True): if stem.startswith(cam + "-"): return cam return None def _camera_from_clip_filename( filename: str, frigate_config: FrigateConfig | None ) -> str | None: """Match a flat clip filename `{camera}-{event_id}[-clean].{ext}` against configured camera names. Longest-prefix wins so camera names containing hyphens (e.g. `front-door`) resolve correctly. """ dot = filename.rfind(".") stem = filename[:dot] if dot > 0 else filename return _longest_prefix_camera(stem, frigate_config) def _camera_from_thumb_filename( filename: str, frigate_config: FrigateConfig | None ) -> str | None: """Match a review thumbnail filename `thumb-{camera}-{review_id}.webp`.""" if not filename.startswith("thumb-"): return None dot = filename.rfind(".") stem = filename[len("thumb-") : dot] if dot > 0 else filename[len("thumb-") :] return _longest_prefix_camera(stem, frigate_config) def _resolve_export( parts: list[str], ) -> tuple[MediaAuthResolution, str | None]: # /exports → multi-camera listing # /exports/{filename}.mp4 → camera (DB lookup by exact path) if len(parts) == 1: return MediaAuthResolution.LISTING_MULTI_CAMERA, None if len(parts) != 2: return MediaAuthResolution.UNRESOLVED_MEDIA, None filename = parts[1] full_path = os.path.join(EXPORT_DIR, filename) try: export = Export.get(Export.video_path == full_path) return MediaAuthResolution.CAMERA, export.camera except DoesNotExist: return MediaAuthResolution.UNRESOLVED_MEDIA, None except Exception as e: logger.warning("Export DB lookup failed for %s: %s", filename, e) return MediaAuthResolution.UNRESOLVED_MEDIA, None def check_camera_access(role: str, camera: str, frigate_config: FrigateConfig) -> bool: """Return True iff `role` may access `camera`. Mirrors the gating logic in `require_camera_access`: admin and any role without a non-empty allow-list bypass the check. """ if role == "admin": return True roles_dict = frigate_config.auth.roles if not roles_dict.get(role): return True all_camera_names = set(frigate_config.cameras.keys()) allowed = User.get_allowed_cameras(role, roles_dict, all_camera_names) return camera in allowed def is_role_restricted(role: str, frigate_config: FrigateConfig) -> bool: """True if `role` has a non-empty allow-list (i.e. not full-access).""" if role == "admin": return False return bool(frigate_config.auth.roles.get(role)) def deny_response_for_media_uri( original_url: str | None, role: str | None, frigate_config: FrigateConfig ) -> int | None: """Decide whether the current role should be blocked from `original_url`. Returns an HTTP status code (403) when access should be denied, or `None` when the request is allowed. """ if not original_url: return None path = extract_path(original_url) # `extract_path` returns None for URIs containing `.` or `..` segments. # For media-root URIs that's a traversal attempt — deny outright. For # non-media URIs, pass through (nginx / the backend handle them). if path is None: raw = urlparse(original_url).path or original_url decoded = unquote(raw) first = decoded.lstrip("/").split("/", 1)[0] if decoded else "" if first in ("clips", "recordings", "exports"): return 403 return None resolution, camera = resolve_media_uri(path, frigate_config) if resolution == MediaAuthResolution.UNKNOWN: return None if not role or role == "admin": return None if not is_role_restricted(role, frigate_config): return None if resolution == MediaAuthResolution.LISTING_NEUTRAL: return None if resolution in ( MediaAuthResolution.LISTING_MULTI_CAMERA, MediaAuthResolution.ADMIN_ONLY, MediaAuthResolution.UNRESOLVED_MEDIA, ): return 403 if resolution == MediaAuthResolution.CAMERA: if camera and check_camera_access(role, camera, frigate_config): return None return 403 return 403