Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

This commit is contained in:
Blake Blackshear
2026-10-15 17:54:06 -05:00
4 changed files with 96 additions and 51 deletions
+5 -34
View File
@@ -12,7 +12,7 @@ import threading
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import parse_qs, unquote
from urllib.parse import parse_qs
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@@ -32,6 +32,8 @@ from frigate.api.media_auth import (
check_camera_access,
deny_response_for_media_uri,
is_role_restricted,
normalize_request_path,
split_request_uri,
)
from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
@@ -1276,37 +1278,6 @@ GO2RTC_STREAM_PROXY_PATHS = frozenset(
}
)
_URI_SCHEME_AND_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?]*")
def _split_request_uri(request_uri: str) -> tuple[str, str]:
"""Split a raw request URI into its path and query string.
urlparse is not used since it reads a path starting with `//` as a host.
"""
uri = _URI_SCHEME_AND_AUTHORITY.sub("", request_uri, count=1)
path, _, query = uri.partition("?")
return path, query
def _normalize_request_path(path: str) -> str:
"""Normalize a raw request path the way nginx does before matching locations.
nginx percent-decodes the path, merges repeated slashes and resolves dot
segments, while `X-Original-URL` carries the raw `$request_uri`.
"""
segments: list[str] = []
for segment in unquote(path).split("/"):
if segment in ("", "."):
continue
if segment == "..":
if segments:
segments.pop()
continue
segments.append(segment)
return "/" + "/".join(segments)
def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
# nginx marks requests routed through the go2rtc stream locations, and the
@@ -1314,7 +1285,7 @@ def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
if request.headers.get("x-go2rtc-stream-proxy") == "1":
return True
normalized = _normalize_request_path(path)
normalized = normalize_request_path(path)
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
@@ -1333,7 +1304,7 @@ def deny_response_for_go2rtc_stream(
if not original_url:
return None
path, query = _split_request_uri(original_url)
path, query = split_request_uri(original_url)
if not _is_go2rtc_stream_proxy_request(path, request):
return None
+42 -10
View File
@@ -13,8 +13,9 @@ from __future__ import annotations
import logging
import os
import re
from enum import Enum
from urllib.parse import unquote, urlparse
from urllib.parse import unquote
from peewee import DoesNotExist
@@ -24,6 +25,8 @@ from frigate.models import Export, User
logger = logging.getLogger(__name__)
_URI_SCHEME_AND_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?]*")
class MediaAuthResolution(str, Enum):
"""Classification of an `X-Original-URL` path for media-auth purposes."""
@@ -41,6 +44,36 @@ class MediaAuthResolution(str, Enum):
UNKNOWN = "unknown"
def split_request_uri(request_uri: str) -> tuple[str, str]:
"""Split a raw request URI into its path and query string.
urlparse is not used since it reads a path starting with `//` as a host.
nginx ends the URI at a literal `#`, so anything after it is dropped.
"""
uri = _URI_SCHEME_AND_AUTHORITY.sub("", request_uri, count=1)
path, _, query = uri.partition("#")[0].partition("?")
return path, query
def normalize_request_path(path: str) -> str:
"""Normalize a raw request path the way nginx does before matching locations.
nginx percent-decodes the path, merges repeated slashes and resolves dot
segments, while `X-Original-URL` carries the raw `$request_uri`.
"""
segments: list[str] = []
for segment in unquote(path).split("/"):
if segment in ("", "."):
continue
if segment == "..":
if segments:
segments.pop()
continue
segments.append(segment)
return "/" + "/".join(segments)
def extract_path(original_url: str | None) -> str | None:
"""Return the decoded path component of nginx's `X-Original-URL` header.
@@ -49,14 +82,14 @@ def extract_path(original_url: str | None) -> str | None:
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.
whose path contains `.` or `..` segments outright. Repeated slashes are
merged, as nginx does before matching locations.
"""
if not original_url:
return None
parsed = urlparse(original_url)
raw_path = parsed.path or original_url
decoded = unquote(raw_path)
path, _ = split_request_uri(original_url)
decoded = re.sub(r"/{2,}", "/", unquote(path))
if not decoded:
return None
@@ -252,12 +285,11 @@ def deny_response_for_media_uri(
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).
# Deny them when nginx would resolve them under a media root, and pass
# the rest through to nginx and the backend.
if path is None:
raw = urlparse(original_url).path or original_url
decoded = unquote(raw)
first = decoded.lstrip("/").split("/", 1)[0] if decoded else ""
normalized = normalize_request_path(split_request_uri(original_url)[0])
first = normalized.lstrip("/").split("/", 1)[0]
if first in ("clips", "recordings", "exports"):
return 403
return None