diff --git a/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf index 619a2b3ff1..9e2827c3d2 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf @@ -15,6 +15,9 @@ location /auth { # Pass info about the request proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $request_uri; + # set by the go2rtc stream locations; client headers are not forwarded, so + # this cannot be spoofed + proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy; proxy_set_header X-Server-Port $server_port; proxy_set_header Content-Length ""; # Pass along auth related info diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index cad314ae35..34cfe966e5 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -202,6 +202,8 @@ http { # frigate lovelace card uses this path location /live/mse/api/ws { + # tells /auth to apply the go2rtc stream checks + set $go2rtc_stream_proxy 1; include auth_request.conf; limit_except GET { deny all; @@ -211,6 +213,8 @@ http { } location /live/webrtc/api/ws { + # tells /auth to apply the go2rtc stream checks + set $go2rtc_stream_proxy 1; include auth_request.conf; limit_except GET { deny all; @@ -231,6 +235,8 @@ http { # integration uses this to add webrtc candidate location /api/go2rtc/webrtc { + # tells /auth to apply the go2rtc stream checks + set $go2rtc_stream_proxy 1; include auth_request.conf; limit_except POST { deny all; diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 4978e4cab7..76d330548e 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -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 diff --git a/frigate/test/test_go2rtc_stream_auth.py b/frigate/test/test_go2rtc_stream_auth.py index f3f4d5ce5f..ddbf579630 100644 --- a/frigate/test/test_go2rtc_stream_auth.py +++ b/frigate/test/test_go2rtc_stream_auth.py @@ -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()