reject cross-origin go2rtc stream requests in /auth

The go2rtc websockets and the webrtc offer POST still accepted any Origin, since go2rtc runs with origin "*" and /auth grants admin on the internal port, so a page on another site could pull live video through a LAN user's browser. nginx now passes Origin, Host, X-Forwarded-Host and Upgrade to /auth, which rejects a websocket upgrade or go2rtc stream request with a foreign Origin before the internal-port early return. The hostname rule moves to frigate/util/origin.py so /auth and the ws4py servers share it.
This commit is contained in:
Josh Hawkins
2026-10-18 12:40:12 -05:00
parent 0aa7cf9de9
commit 31c3638767
6 changed files with 147 additions and 38 deletions
@@ -19,6 +19,11 @@ location /auth {
# this cannot be spoofed
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
proxy_set_header X-Server-Port $server_port;
# inputs for the cross-origin check on websocket and go2rtc stream requests
proxy_set_header Origin $http_origin;
proxy_set_header X-Original-Host $http_host;
proxy_set_header X-Forwarded-Host $forwarded_host;
proxy_set_header X-Original-Upgrade $http_upgrade;
proxy_set_header Content-Length "";
# Pass along auth related info
proxy_set_header Authorization $http_authorization;
+28 -1
View File
@@ -39,6 +39,7 @@ from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
from frigate.notices import raise_notice
from frigate.util.origin import origin_allowed
logger = logging.getLogger(__name__)
@@ -689,6 +690,17 @@ def auth(request: Request):
success_response = Response("", status_code=202)
original_url = request.headers.get("x-original-url")
if _is_cross_origin_stream_request(original_url, request):
logger.warning(
"Rejected cross-origin request: origin=%s, host=%s, x-forwarded-host=%s",
request.headers.get("origin"),
request.headers.get("x-original-host"),
request.headers.get("x-forwarded-host"),
)
return Response("", status_code=403)
# dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed.
# the port is the boot-time snapshot rather than the live config value:
@@ -712,7 +724,6 @@ def auth(request: Request):
logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response
original_url = request.headers.get("x-original-url")
frigate_config = request.app.frigate_config
# if auth is disabled, just apply the proxy header map and return success
@@ -1285,6 +1296,22 @@ def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
def _is_cross_origin_stream_request(original_url: str | None, request: Request) -> bool:
# the internal port grants admin, so a browser on another site must be
# stopped before that early return
upgrade = request.headers.get("x-original-upgrade", "").lower()
if upgrade != "websocket" and not _is_go2rtc_stream_proxy_request(
original_url or "", request
):
return False
return not origin_allowed(
request.headers.get("origin"),
request.headers.get("x-original-host"),
request.headers.get("x-forwarded-host"),
)
def deny_response_for_go2rtc_stream(
original_url: str | None, role: str | None, request: Request
) -> int | None:
+6 -30
View File
@@ -9,7 +9,6 @@ import struct
import threading
from collections.abc import Callable
from typing import Any
from urllib.parse import urlsplit
from wsgiref.simple_server import make_server
from ws4py.server.wsgirefserver import (
@@ -41,6 +40,7 @@ from frigate.const import (
)
from frigate.models import User
from frigate.output.ws_auth import ws_has_camera_access
from frigate.util.origin import origin_allowed
logger = logging.getLogger(__name__)
@@ -473,39 +473,15 @@ def _materialize_for_ws(
return None
def _hostname(value: str) -> str | None:
try:
return urlsplit(value).hostname
except ValueError:
return None
def ws_origin_allowed(environ: dict[str, Any]) -> bool:
"""Return True unless a browser opened the handshake from a foreign origin.
Browsers always send Origin on a WebSocket handshake, so a missing Origin
means a non-browser client. A page cannot add headers to a handshake, so
X-Forwarded-Host comes from a proxy. Ports are ignored because nginx
forwards Host without one.
"""
origin = environ.get("HTTP_ORIGIN")
if origin is None:
return True
origin_host = _hostname(origin)
if not origin_host:
return False
forwarded_host = environ.get("HTTP_X_FORWARDED_HOST", "").split(",")[0].strip()
hosts = (forwarded_host, environ.get("HTTP_HOST", ""))
return any(origin_host == _hostname(f"//{host}") for host in hosts if host)
class OriginCheckedWebSocketApplication(WebSocketWSGIApplication): # type: ignore[misc]
"""ws4py application that rejects cross-origin browser handshakes."""
def __call__(self, environ: dict[str, Any], start_response: Callable) -> Any:
if not ws_origin_allowed(environ):
if not origin_allowed(
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
):
logger.warning(
"Rejected cross-origin websocket handshake: origin=%s, host=%s, "
"x-forwarded-host=%s",
@@ -101,6 +101,71 @@ class TestAuthInternalPort(BaseTestHttp):
self.assertEqual(resp.headers["remote-user"], "anonymous")
self.assertEqual(resp.headers["remote-role"], "admin")
def _internal_auth(self, original_url: str, **headers: str):
app = self._create_app()
with AuthTestClient(app) as client:
return client.get(
"/auth",
headers={
"x-server-port": "5000",
"x-original-url": original_url,
"x-original-host": "frigate.lan:5000",
**{k.replace("_", "-"): v for k, v in headers.items()},
},
)
def test_cross_origin_websocket_on_internal_port_denied(self):
resp = self._internal_auth(
"/ws", origin="https://evil.example", x_original_upgrade="websocket"
)
self.assertEqual(resp.status_code, 403)
def test_cross_origin_go2rtc_stream_denied(self):
resp = self._internal_auth(
"/live/mse/api/ws?src=front_door",
origin="https://evil.example",
x_original_upgrade="websocket",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 403)
def test_cross_origin_webrtc_post_denied(self):
# the offer POST is not an upgrade, so the stream marker alone gates it
resp = self._internal_auth(
"/api/go2rtc/webrtc?src=front_door",
origin="https://evil.example",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 403)
def test_same_origin_websocket_allowed(self):
resp = self._internal_auth(
"/live/mse/api/ws?src=front_door",
origin="http://frigate.lan:5000",
x_original_upgrade="websocket",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 202)
def test_websocket_without_origin_allowed(self):
resp = self._internal_auth("/ws", x_original_upgrade="websocket")
self.assertEqual(resp.status_code, 202)
def test_forwarded_host_origin_allowed(self):
# Home Assistant integration proxy: Host is Frigate, Origin is HA
resp = self._internal_auth(
"/api/go2rtc/webrtc?src=front_door",
origin="https://ha.example.com:8123",
x_forwarded_host="ha.example.com:8123",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 202)
def test_cross_origin_plain_http_not_checked(self):
resp = self._internal_auth("/api/events", origin="https://evil.example")
self.assertEqual(resp.status_code, 202)
def test_external_port_requires_auth(self):
app = self._create_app()
+12 -7
View File
@@ -1,16 +1,21 @@
"""Tests for the WebSocket handshake Origin check."""
"""Tests for the same-origin check on WebSocket handshakes."""
import unittest
from unittest.mock import MagicMock
from frigate.comms.ws import (
OriginCheckedWebSocketApplication,
WebSocket,
ws_origin_allowed,
)
from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket
from frigate.util.origin import origin_allowed
class TestWsOriginAllowed(unittest.TestCase):
def ws_origin_allowed(environ: dict[str, str]) -> bool:
return origin_allowed(
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
)
class TestOriginAllowed(unittest.TestCase):
def test_missing_origin_allowed(self):
self.assertTrue(ws_origin_allowed({"HTTP_HOST": "frigate.lan"}))
+31
View File
@@ -0,0 +1,31 @@
"""Same-origin check for browser WebSocket handshakes and go2rtc stream requests."""
from urllib.parse import urlsplit
def _hostname(value: str) -> str | None:
try:
return urlsplit(value).hostname
except ValueError:
return None
def origin_allowed(
origin: str | None, host: str | None, forwarded_host: str | None
) -> bool:
"""Return True unless a browser sent the request from a foreign origin.
Browsers always send Origin on a WebSocket handshake, so a missing Origin
means a non-browser client. A page cannot add headers to a handshake, so
X-Forwarded-Host comes from a proxy. Ports are ignored because nginx
forwards Host without one.
"""
if origin is None:
return True
origin_host = _hostname(origin)
if not origin_host:
return False
hosts = ((forwarded_host or "").split(",")[0].strip(), host or "")
return any(origin_host == _hostname(f"//{h}") for h in hosts if h)