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.
This commit is contained in:
Josh Hawkins
2026-10-18 12:14:30 -05:00
parent 275e18403e
commit ab8b41934c
7 changed files with 181 additions and 6 deletions
@@ -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;
@@ -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;
+2
View File
@@ -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.
+48 -1
View File
@@ -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(
+2 -3
View File
@@ -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)
+121
View File
@@ -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()
-1
View File
@@ -28,7 +28,6 @@ export default defineConfig({
},
"/live": {
target: `ws://${proxyHost}`,
changeOrigin: true,
ws: true,
},
},