From 714b3b2bd885c070d6404391b390fe349bf6cbf6 Mon Sep 17 00:00:00 2001 From: davidcampbelldc <165905879+davidcampbelldc@users.noreply.github.com> Date: Sun, 17 May 2026 11:36:29 -0700 Subject: [PATCH] fix(web_server): pass proxy_headers=False to uvicorn.run so the dashboard's loopback gate sees the real connection peer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_ws_client_is_allowed()` enforces a loopback-only client check on every dashboard WebSocket upgrade (`/api/ws`, `/api/events`, `/api/pty`, `/api/pub`): def _ws_client_is_allowed(ws): if _is_public_bind(): return True client_host = ws.client.host if ws.client else "" if not client_host: return True return client_host in _LOOPBACK_HOSTS The intent is: when bound to 127.0.0.1, only accept WS upgrades from loopback peers. Public bind (--insecure) trades that for token-only. However, `uvicorn.run(app, host=host, port=port, log_level="warning")` omits `proxy_headers`. In modern uvicorn (>= 0.20) `proxy_headers` defaults to True and `forwarded_allow_ips` defaults to "127.0.0.1". With those defaults, any reverse proxy connecting from loopback (nginx, in-cluster proxy, Cloudflare Tunnel sidecar in HTTP mode, K8s ingress-nginx) causes uvicorn to rewrite `ws.client.host` from the request's `X-Forwarded-For` header. So the gate sees the original client's IP (a public address) instead of the loopback peer, returns False, and closes every browser WS with code=4403 (surfaces as HTTP 403 to the proxy). Passing `proxy_headers=False` keeps the loopback gate's view of `ws.client.host` at the immediate transport peer (the proxy on 127.0.0.1), which is exactly what the gate is designed to check. The bug is invisible in dev (no proxy → no XFF → ws.client.host stays loopback). It surfaces in proxied production: dashboard chat tab opens, events feed banner shows "disconnected — tool calls may not appear", all WS endpoints return 403. Reproduces with: curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: ..." \ -H "X-Forwarded-For: 1.2.3.4" \ "http://127.0.0.1:9119/api/ws?token=\$TOKEN" # Before: HTTP/1.1 403 Forbidden # After: HTTP/1.1 101 Switching Protocols Without the XFF header, both behave the same (101) — confirming the single-variable trigger. Discovered while diagnosing why the Hermes dashboard at mandy.loadmagic.ai (behind nginx + Cloudflare Tunnel + CF Access) refused all browser WS upgrades despite Access app config matching a known-working sibling deployment (Simone, which doesn't have nginx in the path). --- hermes_cli/web_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bdb24554f87..8a1e4aca2e1 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4434,4 +4434,7 @@ def start_server( ) print(f" Hermes Web UI → http://{host}:{port}") - uvicorn.run(app, host=host, port=port, log_level="warning") + # proxy_headers=False so _ws_client_is_allowed sees the real connection peer + # rather than X-Forwarded-For's rewritten value (which would defeat the + # loopback gate when behind a reverse proxy). + uvicorn.run(app, host=host, port=port, log_level="warning", proxy_headers=False)