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:
Blake Blackshear
2026-10-14 08:43:35 -05:00
co-authored by Claude Opus 5.5
parent 9b165948c3
commit fab41048a1
4 changed files with 89 additions and 6 deletions
+47 -5
View File
@@ -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
+33 -1
View File
@@ -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()