mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-20 13:42:47 +03:00
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:
@@ -54,6 +54,13 @@ http {
|
|||||||
default 1;
|
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 {
|
upstream frigate_api {
|
||||||
server 127.0.0.1:5001;
|
server 127.0.0.1:5001;
|
||||||
keepalive 1024;
|
keepalive 1024;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ proxy_set_header Upgrade $http_upgrade;
|
|||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "Upgrade";
|
||||||
proxy_set_header X-Original-URL $request_uri;
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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-URI $request_uri;
|
||||||
proxy_set_header X-Forwarded-Ssl on;
|
proxy_set_header X-Forwarded-Ssl on;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
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
@@ -9,6 +9,7 @@ import struct
|
|||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
from wsgiref.simple_server import make_server
|
from wsgiref.simple_server import make_server
|
||||||
|
|
||||||
from ws4py.server.wsgirefserver import (
|
from ws4py.server.wsgirefserver import (
|
||||||
@@ -472,6 +473,52 @@ def _materialize_for_ws(
|
|||||||
return None
|
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]
|
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -621,7 +668,7 @@ class WebSocketClient(Communicator):
|
|||||||
5002,
|
5002,
|
||||||
server_class=WSGIServer,
|
server_class=WSGIServer,
|
||||||
handler_class=WebSocketWSGIRequestHandler,
|
handler_class=WebSocketWSGIRequestHandler,
|
||||||
app=WebSocketWSGIApplication(handler_cls=_WebSocketHandler),
|
app=OriginCheckedWebSocketApplication(handler_cls=_WebSocketHandler),
|
||||||
)
|
)
|
||||||
self.websocket_server.initialize_websockets_manager()
|
self.websocket_server.initialize_websockets_manager()
|
||||||
self.websocket_thread = threading.Thread(
|
self.websocket_thread = threading.Thread(
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ from ws4py.server.wsgirefserver import (
|
|||||||
WebSocketWSGIRequestHandler,
|
WebSocketWSGIRequestHandler,
|
||||||
WSGIServer,
|
WSGIServer,
|
||||||
)
|
)
|
||||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
|
||||||
|
|
||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
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 import FrigateConfig
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
@@ -136,7 +135,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
8082,
|
8082,
|
||||||
server_class=WSGIServer,
|
server_class=WSGIServer,
|
||||||
handler_class=WebSocketWSGIRequestHandler,
|
handler_class=WebSocketWSGIRequestHandler,
|
||||||
app=WebSocketWSGIApplication(handler_cls=WebSocket),
|
app=OriginCheckedWebSocketApplication(handler_cls=WebSocket),
|
||||||
)
|
)
|
||||||
websocket_server.initialize_websockets_manager()
|
websocket_server.initialize_websockets_manager()
|
||||||
websocket_thread = threading.Thread(target=websocket_server.serve_forever)
|
websocket_thread = threading.Thread(target=websocket_server.serve_forever)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -28,7 +28,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
"/live": {
|
"/live": {
|
||||||
target: `ws://${proxyHost}`,
|
target: `ws://${proxyHost}`,
|
||||||
changeOrigin: true,
|
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user