mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-15 11:12:49 +03:00
Match go2rtc stream-proxy paths the way nginx does
/auth compared the raw $request_uri path against the three go2rtc stream-proxy paths exactly, while nginx matches its prefix locations against the normalized URI. Encoded characters, repeated slashes, dot segments or a suffix on the path skipped the stream check even though nginx still proxied the request to go2rtc. The go2rtc stream locations now set a variable that the auth subrequest forwards as X-Go2rtc-Stream-Proxy (client headers are not forwarded, so it cannot be spoofed). As a fallback, /auth also normalizes the path like nginx (percent-decoding, merging slashes, resolving dot segments) and prefix matches it, and splits the raw URI by hand since urlparse reads a leading // as a host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9b165948c3
commit
fab41048a1
+47
-5
@@ -11,7 +11,7 @@ import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
@@ -1175,7 +1175,8 @@ def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
|
||||
# (see auth_request.conf). Each names the desired stream via the `src` query
|
||||
# param, so the camera-level check must happen here in the `/auth` subrequest —
|
||||
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
|
||||
# endpoint, not these proxied live-stream paths.
|
||||
# endpoint, not these proxied live-stream paths. The nginx locations are prefix
|
||||
# matches, so anything under these paths is proxied too.
|
||||
GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
||||
{
|
||||
"/live/mse/api/ws",
|
||||
@@ -1184,6 +1185,47 @@ 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
|
||||
# normalized path catches them if that marker is ever missing
|
||||
if request.headers.get("x-go2rtc-stream-proxy") == "1":
|
||||
return True
|
||||
|
||||
normalized = _normalize_request_path(path)
|
||||
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
|
||||
|
||||
|
||||
def deny_response_for_go2rtc_stream(
|
||||
original_url: str | None, role: str | None, request: Request
|
||||
@@ -1200,12 +1242,12 @@ def deny_response_for_go2rtc_stream(
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(original_url)
|
||||
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
|
||||
path, query = _split_request_uri(original_url)
|
||||
if not _is_go2rtc_stream_proxy_request(path, request):
|
||||
return None
|
||||
|
||||
frigate_config = request.app.frigate_config
|
||||
sources = parse_qs(parsed.query, keep_blank_values=True).get("src", [])
|
||||
sources = parse_qs(query, keep_blank_values=True).get("src", [])
|
||||
|
||||
# an unknown src would make go2rtc create an ad hoc stream from it, which
|
||||
# bypasses the restricted-source checks on PUT /go2rtc/streams
|
||||
|
||||
@@ -50,7 +50,9 @@ _CONFIG = {
|
||||
|
||||
|
||||
def _request(config: FrigateConfig) -> types.SimpleNamespace:
|
||||
return types.SimpleNamespace(app=types.SimpleNamespace(frigate_config=config))
|
||||
return types.SimpleNamespace(
|
||||
app=types.SimpleNamespace(frigate_config=config), headers={}
|
||||
)
|
||||
|
||||
|
||||
class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
||||
@@ -259,6 +261,36 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
||||
self._deny("http://host/live/mse/api/ws?src=wizard_test", "admin"), 403
|
||||
)
|
||||
|
||||
# --- path spellings nginx normalizes before matching its location ---
|
||||
|
||||
def test_path_variants_are_checked(self):
|
||||
for path in (
|
||||
"/live/mse/api/%77s",
|
||||
"/live/mse/api%2Fws",
|
||||
"//live/mse/api/ws",
|
||||
"/live//mse/api/ws",
|
||||
"/live/mse/api/./ws",
|
||||
"/live/other/../mse/api/ws",
|
||||
"/live/mse/api/%2e/ws",
|
||||
"/%6Cive/webrtc/api/ws",
|
||||
"/api/go2rtc/%77ebrtc",
|
||||
"/live/mse/api/ws/",
|
||||
"/live/mse/api/ws/extra",
|
||||
"http://host//live/mse/api/ws",
|
||||
):
|
||||
self.assertEqual(self._deny(f"{path}?src=nonexistent", "viewer"), 403, path)
|
||||
|
||||
def test_path_variants_allow_existing_stream(self):
|
||||
self.assertIsNone(self._deny("/live/mse/api/%77s?src=front_door", "viewer"))
|
||||
|
||||
def test_nginx_marker_applies_check(self):
|
||||
self.request.headers = {"x-go2rtc-stream-proxy": "1"}
|
||||
self.assertEqual(self._deny("/unexpected?src=nonexistent", "viewer"), 403)
|
||||
|
||||
def test_unrelated_paths_not_checked(self):
|
||||
for path in ("/live/mse/api", "/api/go2rtc/streams", "/live/jsmpeg/x"):
|
||||
self.assertIsNone(self._deny(f"{path}?src=nonexistent", "viewer"), path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user