# NousResearch/hermes-agent · Dashboard WebSocket origin validation blocks legitimate deployments

## Open now

- 2 issues, 1 PR open on GitHub.
- #34390: \[Feature\] dashboard: add --allowed-hosts flag for reverse-proxy and Tailscale access
- #47601: Dashboard broken behind reverse proxy: CORS rejects non-localhost origins + systemd service loses HERMES_TUI_DIR
- PR #37119: feat(dashboard): add durable service support

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.

## TL;DR

- 16 member(s): 2 open issue(s), 9 closed issue(s), 1 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #35141. Verify it addresses your case.
- **Recommended:** read PR #35141 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-35141`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #15731: \[Feature\]: Make chat tab in web dashboard work with --host set to non-localhost address
- #18415: \[Feature\]: Dashboard chat websocket endpoints reject same-host reverse proxy deployments with client_host_not_loopback
- #32615: Dashboard WebSocket connections fail through Cloudflare Tunnel after recent security patch
- #33265: \[Bug\]: dashboard --tui: WebSocket rejected for non-loopback clients even with --insecure / --host 0.0.0.0
- #34396: Dashboard WebSocket endpoints (/api/ws, /api/events) return 403 even with --insecure mode
- #35322: WebSocket connections rejected when dashboard bound to 0.0.0.0 with --insecure
- #50365: Dashboard PTY WebSocket rejects explicit public origins behind loopback proxy
- #54072: \[Bug\]: Dashboard PTY/WebSocket fails behind reverse proxy after June 2026 dashboard security hardening
- #55917: Dashboard rejects WebSocket connections when Origin header doesn't match bind address (blocks CF Tunnel access)

## Prior & competing PRs

- **#17887: fix(cli): honor --insecure for dashboard WebSocket connections**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#35141: fix(dashboard): allow chat websockets on insecure public bind (salvage #33278)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/web_server.py`, `scripts/release.py`, `tests/hermes_cli/test_dashboard_auth_ws_auth.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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
    ```
- **#37119: feat(dashboard): add durable service support**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#34390`
  - files: `cli-config.yaml.example`, `hermes_cli/config.py`, `hermes_cli/dashboard_service.py`, `hermes_cli/main.py`, `hermes_cli/web_server.py`, `tests/hermes_cli/test_dashboard_admin_endpoints.py`, `tests/hermes_cli/test_dashboard_service.py`, `tests/hermes_cli/test_web_server_host_header.py`, `web/src/lib/api.ts`, `web/src/pages/SystemPage.tsx`, `website/docs/reference/cli-commands.md`, `website/docs/user-guide/features/web-dashboard.md`
- **#50380: fix(dashboard): honor explicit websocket origins**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `98192e682e086048c968c67b217ef814d9dcb825`
- **#55970: fix(dashboard): allow forwarded websocket origins**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`

## Fix status

- Merged to main in PR #35141 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-30`.
- Subsystem: `dashboard`
