Dashboard WebSocket origin validation blocks legitimate deployments
NousResearch/hermes-agent · cluster 15731 · dashboard
Data as of
3 open items. Fix merged in #35141. Check it covers your case.
The short answer
- Known?
- Yes. 16 related items grouped here, 9 issues already closed.
- Fix event
- A fix was merged to main in PR #35141. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #35141. That is separate from the fix event. - Still open
- 2 issues and 1 PR.
Next step: read #35141, then check whether your report is one of the 2 still open.
Read #35141Pick up a Lab taskA cluster of bugs and fixes around the dashboard's WebSocket origin/security checks rejecting valid connections from reverse proxies, Cloudflare Tunnels, non-loopback bind addresses, and --insecure mode. Multiple fix attempts address how the dashboard validates origins for /api/ws and /api/events endpoints.
Open issues2
Open PRs1
- PR#37119feat(dashboard): add durable service support
Fixes on record
-
#35141 fix(dashboard): allow chat websockets on insecure public bind (salvage #33278)
files:
hermes_cli/web_server.py,scripts/release.py,tests/hermes_cli/test_dashboard_auth_ws_auth.pyShow the diff
-
hermes_cli/web_server.py@@ -3371,10 +3371,15 @@ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] def _ws_client_is_allowed(ws: "WebSocket") -> bool: """Check if the WebSocket client IP is acceptable. - Loopback mode: only loopback clients allowed — the legacy + Loopback bind: only loopback clients allowed — the legacy ``?token=[redacted:secret-assignment] path is the only auth we have, so we don't want LAN hosts guessing tokens. + All-interfaces insecure bind (``--host 0.0.0.0 --insecure`` or + ``--host :: --insecure``): allow any peer. The operator explicitly + opted into LAN/public exposure in this mode, so the loopback-only peer + restriction should not apply. + Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True`` (enabled when the OAuth gate is active so cookies can pick up ``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the @@ -3385,6 +3390,9 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool: """ if getattr(app.state, "auth_required", False): return True + bound_host = getattr(app.state, "bound_host", "") + if bound_host in {"0.0.0.0", "::"}: + return True client_host = ws.client.host if ws.client else "" if not client_host: return True -
scripts/release.py@@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "SeaXen", "[redacted:email]": "HashClawAI", "[redacted:email]": "TonyPepeBear", "[redacted:email]": "nielskaspers", -
tests/hermes_cli/test_dashboard_auth_ws_auth.py@@ -80,6 +80,25 @@ def loopback_app(): web_server.app.state.auth_required = prev_required [redacted:email] +def insecure_public_app(): + """web_server.app configured for all-interfaces insecure mode.""" + _reset_for_tests() + clear_providers() + prev_host = getattr(web_server.app.state, "bound_host", None) + prev_port = getattr(web_server.app.state, "bound_port", None) + prev_required = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.bound_host = "0.0.0.0" + web_server.app.state.bound_port = 9120 + web_server.app.state.auth_required = False + client = TestClient(web_server.app, base_url="http://192.168.0.222:9120") + yield client + _reset_for_tests() + web_server.app.state.bound_host = prev_host + web_server.app.state.bound_port = prev_port + web_server.app.state.auth_required = prev_required + + def _logged_in(client: TestClient) -> None: """Drive the stub OAuth round trip so the client holds session cookies.""" r1 = client.get("/auth/login?provider=stub", follow_redirects=False) @@ -281,6 +300,21 @@ def test_loopback_peer_allowed_in_loopback_mode(self, loopback_app): ws.headers = {"host": "127.0.0.1:8080"} assert web_server._ws_request_is_allowed(ws) is True + def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app): + """`--host 0.0.0.0 --insecure` is an explicit LAN/public opt-in. + + Regression coverage for the dashboard `/chat` breakage where the + HTML shell loaded on 9120 but every WebSocket upgrade was rejected + with 403 because the loopback-only peer guard still ran even though + the operator intentionally exposed the dashboard on all interfaces. + """ + ws = _fake_ws(query={}, client_host="192.168.0.55") + ws.headers = { + "host": "192.168.0.222:9120", + "origin": "http://192.168.0.222:9120", + } + assert web_server._ws_request_is_allowed(ws) is True + def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app): """Bypassing the peer-IP check must not bypass the DNS-rebinding Host header guard — that one still protects against attacker
-
3 PRs closed without merging
From the close comments: other 3
Closed issues · 9
- Closed#15731[Feature]: Make chat tab in web dashboard work with --host set to non-localhost address
- Closed#18415[Feature]: Dashboard chat websocket endpoints reject same-host reverse proxy deployments with client_host_not_loopback
- Closed#32615Dashboard WebSocket connections fail through Cloudflare Tunnel after recent security patch
- Closed#33265[Bug]: dashboard --tui: WebSocket rejected for non-loopback clients even with --insecure / --host 0.0.0.0
- Closed#34396Dashboard WebSocket endpoints (/api/ws, /api/events) return 403 even with --insecure mode
- Closed#35322WebSocket connections rejected when dashboard bound to 0.0.0.0 with --insecure
- Closed#50365Dashboard PTY WebSocket rejects explicit public origins behind loopback proxy
- Closed#54072[Bug]: Dashboard PTY/WebSocket fails behind reverse proxy after June 2026 dashboard security hardening
- Closed#55917Dashboard rejects WebSocket connections when Origin header doesn't match bind address (blocks CF Tunnel access)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- dashboard
- Merged PRs
- #35141
- Closed issues
- 9
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 30 May 2026