From ab8b41934c11c276c59ee6ca496c7a7e82ee19ec Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:14:30 -0500 Subject: [PATCH] reject cross-origin websocket handshakes Neither /ws nor the jsmpeg websockets checked Origin, and the internal port grants admin to everyone, so any page a LAN user visited could open ws://frigate:5000/ws and send commands or read streams. Both servers now reject a browser handshake whose Origin host matches neither Host nor X-Forwarded-Host, and allow handshakes without an Origin for non-browser clients. nginx now forwards an outer proxy's X-Forwarded-Host instead of overwriting it, so proxies that rewrite Host and the Home Assistant integration's proxy still match. --- .../rootfs/usr/local/nginx/conf/nginx.conf | 7 + .../rootfs/usr/local/nginx/conf/proxy.conf | 2 +- docs/docs/guides/reverse_proxy.md | 2 + frigate/comms/ws.py | 49 ++++++- frigate/output/output.py | 5 +- frigate/test/test_ws_origin.py | 121 ++++++++++++++++++ web/vite.config.ts | 1 - 7 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 frigate/test/test_ws_origin.py diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 690d08f6d0..9d0c1e57d6 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/guides/reverse_proxy.md b/docs/docs/guides/reverse_proxy.md index 0e27f99214..e699c3e229 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. diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index 5f9cb96f59..6888f7c495 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -9,6 +9,7 @@ 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 ( @@ -472,6 +473,52 @@ 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 and cannot add custom + headers to one, so a missing Origin means a non-browser client and a proxy + supplied X-Forwarded-Host cannot be forged by the page. 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): + 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 +668,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..c8df3c5638 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -14,11 +14,10 @@ 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.comms.ws import OriginCheckedWebSocketApplication, WebSocket from frigate.config import FrigateConfig from frigate.config.camera.updater import ( CameraConfigUpdateEnum, @@ -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) diff --git a/frigate/test/test_ws_origin.py b/frigate/test/test_ws_origin.py new file mode 100644 index 0000000000..c07a00b67b --- /dev/null +++ b/frigate/test/test_ws_origin.py @@ -0,0 +1,121 @@ +"""Tests for the WebSocket handshake Origin check.""" + +import unittest +from unittest.mock import MagicMock + +from frigate.comms.ws import ( + OriginCheckedWebSocketApplication, + WebSocket, + ws_origin_allowed, +) + + +class TestWsOriginAllowed(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/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, }, },