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

2open issues
1open PR
16items grouped
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.

A 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

  • Issue#34390[Feature] dashboard: add --allowed-hosts flag for reverse-proxy and Tailscale access
  • Issue#47601Dashboard broken behind reverse proxy: CORS rejects non-localhost origins + systemd service loses HERMES_TUI_DIR

Open PRs1

  • PR#37119feat(dashboard): add durable service support

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35141 fix(dashboard): allow chat websockets on insecure public bind (salvage #33278)

    merged to main

    files: hermes_cli/web_server.py, scripts/release.py, tests/hermes_cli/test_dashboard_auth_ws_auth.py

    Show 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

  • #17887 fix(cli): honor --insecure for dashboard WebSocket connections · other
  • #50380 fix(dashboard): honor explicit websocket origins · other → 98192e6
  • #55970 fix(dashboard): allow forwarded websocket origins · other
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.00 means 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 Lab

At a glance

Subsystem
dashboard
Merged PRs
#35141
Closed issues
9
Attempts
3 PRs closed without merging
Fix link checked
30 May 2026

For your agent

The same facts, typed.