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 b14cd6ef50..2ff7bf9d9f 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf @@ -20,6 +20,11 @@ location /auth { proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy; proxy_set_header X-Server-Port $server_port; proxy_set_header X-Forwarded-Proto $scheme; + # 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/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 96b2b3f085..5884a11ed5 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -54,6 +54,13 @@ http { default 1; } + # keep an outer proxy's X-Forwarded-Host so the websocket servers can + # match a browser's Origin when that proxy rewrites Host + map $http_x_forwarded_host $forwarded_host { + "" $http_host; + default $http_x_forwarded_host; + } + upstream frigate_api { server 127.0.0.1:5001; keepalive 1024; diff --git a/docker/main/rootfs/usr/local/nginx/conf/proxy.conf b/docker/main/rootfs/usr/local/nginx/conf/proxy.conf index 7ab5b58d15..884d8a02a9 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/proxy.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/proxy.conf @@ -4,7 +4,7 @@ proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header X-Original-URL $request_uri; proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-Forwarded-Host $http_host; +proxy_set_header X-Forwarded-Host $forwarded_host; proxy_set_header X-Forwarded-URI $request_uri; proxy_set_header X-Forwarded-Ssl on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docs/docs/configuration/advanced/system.md b/docs/docs/configuration/advanced/system.md index a8cff3b827..99703ecd5e 100644 --- a/docs/docs/configuration/advanced/system.md +++ b/docs/docs/configuration/advanced/system.md @@ -360,6 +360,7 @@ For example, in Nginx: ``` location /frigate { + proxy_set_header Host $host; proxy_set_header X-Ingress-Path /frigate; proxy_pass http://frigate_backend; } diff --git a/docs/docs/guides/reverse_proxy.md b/docs/docs/guides/reverse_proxy.md index 0e27f99214..41a9c15a6f 100644 --- a/docs/docs/guides/reverse_proxy.md +++ b/docs/docs/guides/reverse_proxy.md @@ -37,6 +37,8 @@ Your reverse proxy must be configured to forward the `Upgrade` and `Connection` Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled. +Frigate rejects WebSocket connections opened from a page on another site. Your proxy must either preserve the original `Host` header or send it in `X-Forwarded-Host`, otherwise Frigate cannot tell the connection came from its own UI. + ## Proxies There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page. @@ -161,6 +163,7 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr error_log /data/logs/proxy-host-40_error.log warn; location / { + proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_http_version 1.1; diff --git a/docs/docs/integrations/home-assistant.md b/docs/docs/integrations/home-assistant.md index f5ca05f1a4..66525bd8c1 100644 --- a/docs/docs/integrations/home-assistant.md +++ b/docs/docs/integrations/home-assistant.md @@ -55,6 +55,10 @@ To install the optional companion Lovelace card, please see the [separate installation instructions](https://github.com/dermotduffy/frigate-hass-card) for that card. +Frigate rejects browser connections to its live streams that come from another +origin, such as a Home Assistant dashboard. Configure cards to stream through the +integration's proxy rather than pointing them directly at Frigate's port. + ## Configuration When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://:5000/`. diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 2ed059bf82..c3f8f2d623 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__) @@ -718,6 +719,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: @@ -741,7 +753,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 @@ -1307,6 +1318,21 @@ 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() + path = split_request_uri(original_url or "")[0] + if upgrade != "websocket" and not _is_go2rtc_stream_proxy_request(path, 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 5f9cb96f59..d7a419b05b 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -40,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__) @@ -472,6 +473,28 @@ def _materialize_for_ws( return None +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 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", + environ.get("HTTP_ORIGIN"), + environ.get("HTTP_HOST"), + environ.get("HTTP_X_FORWARDED_HOST"), + ) + start_response("403 Forbidden", [("Content-Length", "0")]) + return [] + + return super().__call__(environ, start_response) + + class WebSocket(WebSocket_): # type: ignore[misc] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -621,7 +644,7 @@ class WebSocketClient(Communicator): 5002, server_class=WSGIServer, handler_class=WebSocketWSGIRequestHandler, - app=WebSocketWSGIApplication(handler_cls=_WebSocketHandler), + app=OriginCheckedWebSocketApplication(handler_cls=_WebSocketHandler), ) self.websocket_server.initialize_websockets_manager() self.websocket_thread = threading.Thread( diff --git a/frigate/output/output.py b/frigate/output/output.py index 7a48f3e833..b40a0f4cd0 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -14,12 +14,11 @@ from ws4py.server.wsgirefserver import ( WebSocketWSGIRequestHandler, WSGIServer, ) -from ws4py.server.wsgiutils import WebSocketWSGIApplication from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum -from frigate.comms.ws import WebSocket -from frigate.config import FrigateConfig +from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket +from frigate.config import AuthConfig, FrigateConfig from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, @@ -136,7 +135,7 @@ class OutputProcess(FrigateProcess): 8082, server_class=WSGIServer, handler_class=WebSocketWSGIRequestHandler, - app=WebSocketWSGIApplication(handler_cls=WebSocket), + app=OriginCheckedWebSocketApplication(handler_cls=WebSocket), ) websocket_server.initialize_websockets_manager() websocket_thread = threading.Thread(target=websocket_server.serve_forever) @@ -154,6 +153,7 @@ class OutputProcess(FrigateProcess): ], ) birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) + auth_config_subscriber = ConfigSubscriber("config/auth", exact=True) jsmpeg_cameras: dict[str, JsmpegCamera] = {} birdseye: Birdseye | None = None @@ -194,6 +194,15 @@ class OutputProcess(FrigateProcess): self.config.birdseye = birdseye_config logger.debug("Applied dynamic birdseye config update") + # user endpoints publish None on this topic and role edits an AuthConfig + while True: + auth_topic, auth_config = auth_config_subscriber.check_for_update() + if auth_topic is None: + break + if isinstance(auth_config, AuthConfig): + self.config.auth = auth_config + logger.debug("Applied dynamic auth config update") + # drain review updates every iteration, not just when birdseye is # being consumed, so a dropped end never strands a camera if birdseye is not None: @@ -330,6 +339,7 @@ class OutputProcess(FrigateProcess): config_subscriber.stop() birdseye_config_subscriber.stop() + auth_config_subscriber.stop() websocket_server.manager.close_all() websocket_server.manager.stop() websocket_server.manager.join() 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_output_ws_auth.py b/frigate/test/test_output_ws_auth.py index ea4834ef13..7f5dce58d7 100644 --- a/frigate/test/test_output_ws_auth.py +++ b/frigate/test/test_output_ws_auth.py @@ -3,7 +3,7 @@ import unittest from types import SimpleNamespace -from frigate.config import FrigateConfig +from frigate.config import AuthConfig, FrigateConfig from frigate.output.ws_auth import ws_has_camera_access @@ -55,3 +55,11 @@ class TestWsHasCameraAccess(unittest.TestCase): self.assertFalse( ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config) ) + + def test_replaced_auth_config_revokes_camera(self): + ws = self._make_ws("limited_user") + self.assertTrue(ws_has_camera_access(ws, "front_door", self.config)) + + self.config.auth = AuthConfig(roles={"limited_user": ["back_door"]}) + self.assertFalse(ws_has_camera_access(ws, "front_door", self.config)) + self.assertTrue(ws_has_camera_access(ws, "back_door", self.config)) diff --git a/frigate/test/test_ws_origin.py b/frigate/test/test_ws_origin.py new file mode 100644 index 0000000000..862f0cfcc8 --- /dev/null +++ b/frigate/test/test_ws_origin.py @@ -0,0 +1,126 @@ +"""Tests for the same-origin check on WebSocket handshakes.""" + +import unittest +from unittest.mock import MagicMock + +from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket +from frigate.util.origin import origin_allowed + + +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"})) + + def test_direct_access_same_origin(self): + # nginx forwards Host without the port and X-Forwarded-Host with it + environ = { + "HTTP_ORIGIN": "http://192.168.1.10:5000", + "HTTP_HOST": "192.168.1.10", + "HTTP_X_FORWARDED_HOST": "192.168.1.10:5000", + } + self.assertTrue(ws_origin_allowed(environ)) + + def test_default_port_origin(self): + environ = { + "HTTP_ORIGIN": "https://frigate.example.com", + "HTTP_HOST": "frigate.example.com", + } + self.assertTrue(ws_origin_allowed(environ)) + + def test_host_comparison_is_case_insensitive(self): + environ = { + "HTTP_ORIGIN": "https://Frigate.Example.com", + "HTTP_HOST": "frigate.example.COM", + } + self.assertTrue(ws_origin_allowed(environ)) + + def test_ipv6_host(self): + environ = {"HTTP_ORIGIN": "http://[::1]:8971", "HTTP_HOST": "[::1]"} + self.assertTrue(ws_origin_allowed(environ)) + + def test_cross_origin_rejected(self): + environ = { + "HTTP_ORIGIN": "https://evil.example", + "HTTP_HOST": "frigate.lan", + "HTTP_X_FORWARDED_HOST": "frigate.lan:5000", + } + self.assertFalse(ws_origin_allowed(environ)) + + def test_null_origin_rejected(self): + self.assertFalse( + ws_origin_allowed({"HTTP_ORIGIN": "null", "HTTP_HOST": "frigate.lan"}) + ) + + def test_proxy_rewriting_host_with_forwarded_host(self): + environ = { + "HTTP_ORIGIN": "https://frigate.example.com", + "HTTP_HOST": "10.0.0.5", + "HTTP_X_FORWARDED_HOST": "frigate.example.com", + } + self.assertTrue(ws_origin_allowed(environ)) + + def test_forwarded_host_list_uses_first_entry(self): + environ = { + "HTTP_ORIGIN": "https://frigate.example.com", + "HTTP_HOST": "10.0.0.5", + "HTTP_X_FORWARDED_HOST": "frigate.example.com, proxy.internal", + } + self.assertTrue(ws_origin_allowed(environ)) + + environ["HTTP_ORIGIN"] = "https://proxy.internal" + self.assertFalse(ws_origin_allowed(environ)) + + def test_home_assistant_ingress(self): + # HA core copies Host and sets X-Forwarded-Host to its own host + environ = { + "HTTP_ORIGIN": "https://ha.example.com:8123", + "HTTP_HOST": "ha.example.com", + "HTTP_X_FORWARDED_HOST": "ha.example.com:8123", + "HTTP_X_INGRESS_PATH": "/api/hassio_ingress/abc123", + } + self.assertTrue(ws_origin_allowed(environ)) + + def test_ingress_path_does_not_bypass_origin_check(self): + environ = { + "HTTP_ORIGIN": "https://evil.example", + "HTTP_HOST": "frigate.example.com", + "HTTP_X_INGRESS_PATH": "/frigate", + } + self.assertFalse(ws_origin_allowed(environ)) + + def test_home_assistant_integration_proxy(self): + # hass-web-proxy-lib drops Host, so Host is the integration's Frigate URL + environ = { + "HTTP_ORIGIN": "https://ha.example.com:8123", + "HTTP_HOST": "frigate.lan", + "HTTP_X_FORWARDED_HOST": "ha.example.com:8123", + } + self.assertTrue(ws_origin_allowed(environ)) + + +class TestOriginCheckedWebSocketApplication(unittest.TestCase): + def test_cross_origin_handshake_gets_403(self): + app = OriginCheckedWebSocketApplication(handler_cls=WebSocket) + start_response = MagicMock() + environ = { + "REQUEST_METHOD": "GET", + "HTTP_ORIGIN": "https://evil.example", + "HTTP_HOST": "frigate.lan", + } + + self.assertEqual(app(environ, start_response), []) + start_response.assert_called_once() + self.assertEqual(start_response.call_args.args[0], "403 Forbidden") + self.assertNotIn("ws4py.websocket", environ) + + +if __name__ == "__main__": + unittest.main() 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) diff --git a/web/vite.config.ts b/web/vite.config.ts index 51d9dcc4f0..d3465272b2 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -28,7 +28,6 @@ export default defineConfig({ }, "/live": { target: `ws://${proxyHost}`, - changeOrigin: true, ws: true, }, },