Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
69 changes: 69 additions & 0 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading