mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-15 03:02: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
@@ -15,6 +15,9 @@ location /auth {
|
|||||||
# Pass info about the request
|
# Pass info about the request
|
||||||
proxy_set_header X-Original-Method $request_method;
|
proxy_set_header X-Original-Method $request_method;
|
||||||
proxy_set_header X-Original-URL $request_uri;
|
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 X-Server-Port $server_port;
|
||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
# Pass along auth related info
|
# Pass along auth related info
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ http {
|
|||||||
|
|
||||||
# frigate lovelace card uses this path
|
# frigate lovelace card uses this path
|
||||||
location /live/mse/api/ws {
|
location /live/mse/api/ws {
|
||||||
|
# tells /auth to apply the go2rtc stream checks
|
||||||
|
set $go2rtc_stream_proxy 1;
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
limit_except GET {
|
limit_except GET {
|
||||||
deny all;
|
deny all;
|
||||||
@@ -211,6 +213,8 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /live/webrtc/api/ws {
|
location /live/webrtc/api/ws {
|
||||||
|
# tells /auth to apply the go2rtc stream checks
|
||||||
|
set $go2rtc_stream_proxy 1;
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
limit_except GET {
|
limit_except GET {
|
||||||
deny all;
|
deny all;
|
||||||
@@ -231,6 +235,8 @@ http {
|
|||||||
|
|
||||||
# integration uses this to add webrtc candidate
|
# integration uses this to add webrtc candidate
|
||||||
location /api/go2rtc/webrtc {
|
location /api/go2rtc/webrtc {
|
||||||
|
# tells /auth to apply the go2rtc stream checks
|
||||||
|
set $go2rtc_stream_proxy 1;
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
limit_except POST {
|
limit_except POST {
|
||||||
deny all;
|
deny all;
|
||||||
|
|||||||
+47
-5
@@ -11,7 +11,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, unquote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
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
|
# (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 —
|
# param, so the camera-level check must happen here in the `/auth` subrequest —
|
||||||
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
|
# `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(
|
GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
||||||
{
|
{
|
||||||
"/live/mse/api/ws",
|
"/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(
|
def deny_response_for_go2rtc_stream(
|
||||||
original_url: str | None, role: str | None, request: Request
|
original_url: str | None, role: str | None, request: Request
|
||||||
@@ -1200,12 +1242,12 @@ def deny_response_for_go2rtc_stream(
|
|||||||
if not original_url:
|
if not original_url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
parsed = urlparse(original_url)
|
path, query = _split_request_uri(original_url)
|
||||||
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
|
if not _is_go2rtc_stream_proxy_request(path, request):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
frigate_config = request.app.frigate_config
|
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
|
# an unknown src would make go2rtc create an ad hoc stream from it, which
|
||||||
# bypasses the restricted-source checks on PUT /go2rtc/streams
|
# bypasses the restricted-source checks on PUT /go2rtc/streams
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ _CONFIG = {
|
|||||||
|
|
||||||
|
|
||||||
def _request(config: FrigateConfig) -> types.SimpleNamespace:
|
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):
|
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
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user