{"repo":"NousResearch/hermes-agent","public_id":"15731","display_label":"Dashboard WebSocket origin validation blocks legitimate deployments","summary":"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.","subsystem":"dashboard","counts":{"members":16,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":9,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35141},"resolution":{"state":"partially_resolved","by":"pr-35141"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T07:23:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10399999999999997},"fix_edges":[{"issue_number":34227,"pr_number":35141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34396,"pr_number":35141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":15731,"title":"[Feature]: Make chat tab in web dashboard work with --host set to non-localhost address","url":null,"state":"closed"},{"kind":"issue","number":18415,"title":"[Feature]: Dashboard chat websocket endpoints reject same-host reverse proxy deployments with client_host_not_loopback","url":null,"state":"closed"},{"kind":"issue","number":32615,"title":"Dashboard WebSocket connections fail through Cloudflare Tunnel after recent security patch","url":null,"state":"closed"},{"kind":"issue","number":33265,"title":"[Bug]: dashboard --tui: WebSocket rejected for non-loopback clients even with --insecure / --host 0.0.0.0","url":null,"state":"closed"},{"kind":"issue","number":34390,"title":"[Feature] dashboard: add --allowed-hosts flag for reverse-proxy and Tailscale access","url":null,"state":"open"},{"kind":"issue","number":34396,"title":"Dashboard WebSocket endpoints (/api/ws, /api/events) return 403 even with --insecure mode","url":null,"state":"closed"},{"kind":"issue","number":35322,"title":"WebSocket connections rejected when dashboard bound to 0.0.0.0 with --insecure","url":null,"state":"closed"},{"kind":"issue","number":47601,"title":"Dashboard broken behind reverse proxy: CORS rejects non-localhost origins + systemd service loses HERMES_TUI_DIR","url":null,"state":"open"},{"kind":"issue","number":50365,"title":"Dashboard PTY WebSocket rejects explicit public origins behind loopback proxy","url":null,"state":"closed"},{"kind":"issue","number":54072,"title":"[Bug]: Dashboard PTY/WebSocket fails behind reverse proxy after June 2026 dashboard security hardening","url":null,"state":"closed"},{"kind":"issue","number":55917,"title":"Dashboard rejects WebSocket connections when Origin header doesn't match bind address (blocks CF Tunnel access)","url":null,"state":"closed"},{"kind":"pr","number":17887,"title":"fix(cli): honor --insecure for dashboard WebSocket connections","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":35141,"title":"fix(dashboard): allow chat websockets on insecure public bind (salvage #33278)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","scripts/release.py","tests/hermes_cli/test_dashboard_auth_ws_auth.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -3371,10 +3371,15 @@ class PtyUnavailableError(RuntimeError):  # type: ignore[no-redef]\n def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:\n     \"\"\"Check if the WebSocket client IP is acceptable.\n \n-    Loopback mode: only loopback clients allowed — the legacy\n+    Loopback bind: only loopback clients allowed — the legacy\n     ``?token=[redacted:secret-assignment] path is the only auth we have, so we\n     don't want LAN hosts guessing tokens.\n \n+    All-interfaces insecure bind (``--host 0.0.0.0 --insecure`` or\n+    ``--host :: --insecure``): allow any peer. The operator explicitly\n+    opted into LAN/public exposure in this mode, so the loopback-only peer\n+    restriction should not apply.\n+\n     Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True``\n     (enabled when the OAuth gate is active so cookies can pick up\n     ``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the","@@ -3385,6 +3390,9 @@ def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:\n     \"\"\"\n     if getattr(app.state, \"auth_required\", False):\n         return True\n+    bound_host = getattr(app.state, \"bound_host\", \"\")\n+    if bound_host in {\"0.0.0.0\", \"::\"}:\n+        return True\n     client_host = ws.client.host if ws.client else \"\"\n     if not client_host:\n         return True"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"SeaXen\",\n     \"[redacted:email]\": \"HashClawAI\",\n     \"[redacted:email]\": \"TonyPepeBear\",\n     \"[redacted:email]\": \"nielskaspers\","]},{"file":"tests/hermes_cli/test_dashboard_auth_ws_auth.py","hunks":["@@ -80,6 +80,25 @@ def loopback_app():\n     web_server.app.state.auth_required = prev_required\n \n \n[redacted:email]\n+def insecure_public_app():\n+    \"\"\"web_server.app configured for all-interfaces insecure mode.\"\"\"\n+    _reset_for_tests()\n+    clear_providers()\n+    prev_host = getattr(web_server.app.state, \"bound_host\", None)\n+    prev_port = getattr(web_server.app.state, \"bound_port\", None)\n+    prev_required = getattr(web_server.app.state, \"auth_required\", None)\n+    web_server.app.state.bound_host = \"0.0.0.0\"\n+    web_server.app.state.bound_port = 9120\n+    web_server.app.state.auth_required = False\n+    client = TestClient(web_server.app, base_url=\"http://192.168.0.222:9120\")\n+    yield client\n+    _reset_for_tests()\n+    web_server.app.state.bound_host = prev_host\n+    web_server.app.state.bound_port = prev_port\n+    web_server.app.state.auth_required = prev_required\n+\n+\n def _logged_in(client: TestClient) -> None:\n     \"\"\"Drive the stub OAuth round trip so the client holds session cookies.\"\"\"\n     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):\n         ws.headers = {\"host\": \"127.0.0.1:8080\"}\n         assert web_server._ws_request_is_allowed(ws) is True\n \n+    def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app):\n+        \"\"\"`--host 0.0.0.0 --insecure` is an explicit LAN/public opt-in.\n+\n+        Regression coverage for the dashboard `/chat` breakage where the\n+        HTML shell loaded on 9120 but every WebSocket upgrade was rejected\n+        with 403 because the loopback-only peer guard still ran even though\n+        the operator intentionally exposed the dashboard on all interfaces.\n+        \"\"\"\n+        ws = _fake_ws(query={}, client_host=\"192.168.0.55\")\n+        ws.headers = {\n+            \"host\": \"192.168.0.222:9120\",\n+            \"origin\": \"http://192.168.0.222:9120\",\n+        }\n+        assert web_server._ws_request_is_allowed(ws) is True\n+\n     def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app):\n         \"\"\"Bypassing the peer-IP check must not bypass the DNS-rebinding\n         Host header guard — that one still protects against attacker\n"]}]},{"kind":"pr","number":37119,"title":"feat(dashboard): add durable service support","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#34390","number":34390}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":50380,"title":"fix(dashboard): honor explicit websocket origins","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"98192e682e086048c968c67b217ef814d9dcb825","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55970,"title":"fix(dashboard): allow forwarded websocket origins","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}