fix(dashboard): allow WebSocket connections on non-loopback binds#25072
fix(dashboard): allow WebSocket connections on non-loopback binds#25072mrSutivu wants to merge 1 commit into
Conversation
Three issues prevented the dashboard chat tab from connecting when the web UI is bound to a non-loopback address (e.g. Tailscale IP): 1. The HTTP auth middleware rejected WebSocket upgrade requests because session tokens are passed via query params (browsers can't set custom headers on WS upgrades). Add /api/pty, /api/ws, /api/events, and /api/pub to the public paths — each handler validates the token itself. 2. The CORS origin regex only matched localhost/127.0.0.1. Extend it to also allow 0.0.0.0 and Tailscale (100.x.x.x) origins. 3. _ws_client_is_allowed only checked for 0.0.0.0/:: bind but not explicit non-loopback addresses. Allow any client when the operator intentionally binds to a specific non-loopback address (--insecure).
There was a problem hiding this comment.
Pull request overview
Fixes dashboard chat WebSocket connectivity when the web UI is bound to non-loopback interfaces (e.g., Tailscale IPs with --insecure) by adjusting CORS, auth allowlisting, and WebSocket client-allow rules in the FastAPI server.
Changes:
- Expands CORS
allow_origin_regexto permit additional non-loopback origins. - Adds WebSocket endpoints (
/api/pty,/api/ws,/api/events,/api/pub) to the auth middleware allowlist, relying on query-param token validation in the WS handlers. - Updates
_ws_client_is_allowedto allow non-loopback WebSocket clients when the server is bound to a specific non-loopback host.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| app.add_middleware( | ||
| CORSMiddleware, | ||
| allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", | ||
| allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1|0\.0\.0\.0|100\.\d+\.\d+\.\d+)(:\d+)?$", | ||
| allow_methods=["*"], |
| return True | ||
| # When bound to a specific non-loopback address (Tailscale IP, etc.) | ||
| # with --insecure, allow any client — the operator explicitly chose | ||
| # a non-loopback bind. | ||
| bound_host = getattr(app.state, "bound_host", "") | ||
| if bound_host and bound_host not in _LOOPBACK_HOSTS: | ||
| return True |
|
Landed in PR #35386 (merge commit 234ac00), now on |
Problem
The dashboard chat tab fails to connect when the web UI is bound to a non-loopback address (e.g. a Tailscale IP with
--insecure). The button shows "reconnect" but the WebSocket never establishes. Three distinct bugs contribute to this:1. HTTP auth middleware blocks WebSocket upgrades
The
auth_middlewareenforcesX-Hermes-Session-TokenorAuthorization: Bearerheaders on all/api/*routes except a hardcoded allowlist. WebSocket endpoints use query params (?token=...) because browsers cannot set custom headers during the WS upgrade./api/pty,/api/ws,/api/events, and/api/pubwere not on the allowlist, so the middleware returned 401 before the WS handler could validate the token.2. CORS blocks non-loopback origins
The
allow_origin_regexonly acceptedlocalhostand127.0.0.1. Accessing the dashboard from a Tailscale IP (or any other non-loopback bind) failed CORS preflight checks.3.
_ws_client_is_allowedrejects non-localhost clientsThis function only checked for
0.0.0.0/::binds (_is_public_bind) when deciding whether to accept non-loopback WebSocket clients. An explicit non-loopback bind (like a specific Tailscale IP with--insecure) fell through to the localhost-only check, rejecting the connection.Fixes
_PUBLIC_API_PATHSso the HTTP auth middleware doesnt intercept them (they validate tokens themselves via query params).0.0.0.0and Tailscale IP range (100.x.x.x)._ws_client_is_allowedto allow any client whenbound_hostis a specific non-loopback address.