From 2e66eefbc3251067f83a13d0a32ca51524f4f9a2 Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Fri, 22 May 2026 12:29:48 +0800 Subject: [PATCH] fix(dashboard): validate WebSocket Host and Origin --- hermes_cli/web_server.py | 41 +++++++++-- .../hermes_cli/test_web_server_host_header.py | 69 +++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 77866b990ac..eee068d1209 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3305,6 +3305,39 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool: return True return client_host in _LOOPBACK_HOSTS + +def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: + """Apply the dashboard Host/Origin guard to WebSocket upgrades. + + FastAPI HTTP middleware does not run for WebSocket routes, so the + DNS-rebinding Host check used for normal dashboard HTTP requests must be + repeated here before accepting the upgrade. Browsers also send an Origin + header on WebSocket handshakes; when present, require it to target the + same bound dashboard host. + """ + bound_host = getattr(app.state, "bound_host", None) + if not bound_host: + return True + + host_header = ws.headers.get("host", "") + if not _is_accepted_host(host_header, bound_host): + return False + + origin = ws.headers.get("origin", "") + if not origin: + return True + + parsed = urllib.parse.urlparse(origin) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return False + + return _is_accepted_host(parsed.netloc, bound_host) + + +def _ws_request_is_allowed(ws: "WebSocket") -> bool: + """Return True when the WebSocket upgrade matches dashboard boundaries.""" + return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws) + # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber @@ -3406,7 +3439,7 @@ async def pty_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - if not _ws_client_is_allowed(ws): + if not _ws_request_is_allowed(ws): await ws.close(code=4403) return @@ -3525,7 +3558,7 @@ async def gateway_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - if not _ws_client_is_allowed(ws): + if not _ws_request_is_allowed(ws): await ws.close(code=4403) return @@ -3557,7 +3590,7 @@ async def pub_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - if not _ws_client_is_allowed(ws): + if not _ws_request_is_allowed(ws): await ws.close(code=4403) return @@ -3586,7 +3619,7 @@ async def events_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - if not _ws_client_is_allowed(ws): + if not _ws_request_is_allowed(ws): await ws.close(code=4403) return diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 966127b05ce..9afef09d136 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -146,3 +146,72 @@ class TestHostHeaderMiddleware: resp = client.get("/api/status") # Should get through to the status endpoint, not a 400 assert resp.status_code != 400 + + +class TestWebSocketHostOriginGuard: + """WebSocket upgrades must enforce the same dashboard boundary as HTTP.""" + + def test_rebinding_websocket_host_is_rejected(self, monkeypatch): + from fastapi.testclient import TestClient + from starlette.websockets import WebSocketDisconnect + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False) + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + + client = TestClient(ws.app) + url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test" + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect( + url, + headers={ + "Host": "evil.example", + "Origin": "http://evil.example", + }, + ): + pass + + assert exc.value.code == 4403 + + def test_rebinding_websocket_origin_is_rejected(self, monkeypatch): + from fastapi.testclient import TestClient + from starlette.websockets import WebSocketDisconnect + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False) + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + + client = TestClient(ws.app) + url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test" + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect( + url, + headers={ + "Host": "localhost:9119", + "Origin": "http://evil.example", + }, + ): + pass + + assert exc.value.code == 4403 + + def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch): + from fastapi.testclient import TestClient + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False) + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + + client = TestClient(ws.app) + url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test" + with client.websocket_connect( + url, + headers={ + "Host": "localhost:9119", + "Origin": "http://localhost:9119", + }, + ): + pass