diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d5d319dda095b..500ce2e9f753e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3314,6 +3314,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 @@ -3415,7 +3448,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 @@ -3534,7 +3567,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 @@ -3566,7 +3599,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 @@ -3595,7 +3628,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.py b/tests/hermes_cli/test_web_server.py index d3143a4092ac2..6950a919d89c2 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2285,7 +2285,10 @@ def fake_resolve(resume=None, sidecar_url=None): self.ws_module.app.state, "bound_port", 9119, raising=False ) - with self.client.websocket_connect(self._url(channel="abc-123")) as conn: + headers = {"host": "127.0.0.1:9119", "origin": "http://127.0.0.1:9119"} + with self.client.websocket_connect( + self._url(channel="abc-123"), headers=headers + ) as conn: try: conn.receive_bytes() except Exception: diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 966127b05ce65..9afef09d136d9 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 @@ def test_no_bound_host_skips_validation(self): 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