From 585d6778da28f4a63205d95a296358e2cce23ed6 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 2 May 2026 08:17:45 +0530 Subject: [PATCH] fix: allow WebSocket connections from non-loopback IPs in --insecure mode (#18633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dashboard is bound to 0.0.0.0 with --insecure (e.g. behind Tailscale Serve), WebSocket endpoints (/api/pty, /api/ws, /api/pub, /api/events) rejected connections from non-loopback client IPs with code 4403 — causing 'events feed disconnected' in the UI. Extract the repeated loopback check into _ws_client_is_allowed() which respects the public bind flag. Session token auth still guards all endpoints regardless of bind mode. --- hermes_cli/web_server.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9c78b6775a..014a938e07 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2882,6 +2882,25 @@ _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") # loopback so tests don't need to rewrite request scope. _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) + +def _is_public_bind() -> bool: + """True when bound to all-interfaces (operator used --insecure).""" + return getattr(app.state, "bound_host", "") in ("0.0.0.0", "::") + + +def _ws_client_is_allowed(ws: "WebSocket") -> bool: + """Check if the WebSocket client IP is acceptable. + + Allows loopback always; allows any IP when bound to all-interfaces + (--insecure mode, guarded by session token auth). + """ + 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 + # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber @@ -2972,8 +2991,7 @@ async def pty_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - client_host = ws.client.host if ws.client else "" - if client_host and client_host not in _LOOPBACK_HOSTS: + if not _ws_client_is_allowed(ws): await ws.close(code=4403) return @@ -3080,8 +3098,7 @@ async def gateway_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - client_host = ws.client.host if ws.client else "" - if client_host and client_host not in _LOOPBACK_HOSTS: + if not _ws_client_is_allowed(ws): await ws.close(code=4403) return @@ -3113,8 +3130,7 @@ async def pub_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - client_host = ws.client.host if ws.client else "" - if client_host and client_host not in _LOOPBACK_HOSTS: + if not _ws_client_is_allowed(ws): await ws.close(code=4403) return @@ -3143,8 +3159,7 @@ async def events_ws(ws: WebSocket) -> None: await ws.close(code=4401) return - client_host = ws.client.host if ws.client else "" - if client_host and client_host not in _LOOPBACK_HOSTS: + if not _ws_client_is_allowed(ws): await ws.close(code=4403) return