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 9e2827c3d2..3d2ed0ccb9 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf @@ -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; diff --git a/frigate/api/auth.py b/frigate/api/auth.py index b3c730df71..940dbd7ca1 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -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: diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index 6d83ff1e3c..d7a419b05b 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -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", diff --git a/frigate/test/http_api/test_http_auth_internal_port.py b/frigate/test/http_api/test_http_auth_internal_port.py index 75c36bd976..bbb28c82b7 100644 --- a/frigate/test/http_api/test_http_auth_internal_port.py +++ b/frigate/test/http_api/test_http_auth_internal_port.py @@ -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() diff --git a/frigate/test/test_ws_origin.py b/frigate/test/test_ws_origin.py index c07a00b67b..862f0cfcc8 100644 --- a/frigate/test/test_ws_origin.py +++ b/frigate/test/test_ws_origin.py @@ -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"})) diff --git a/frigate/util/origin.py b/frigate/util/origin.py new file mode 100644 index 0000000000..4836dddce4 --- /dev/null +++ b/frigate/util/origin.py @@ -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)