fix(dashboard): use loopback host for in-container WebSocket client (#58993) [salvage #59682] (#60092)

* fix(dashboard): use loopback host for in-container WebSocket client (#58993)

Fixes #58993 - the in-container Dashboard's WebSocket client was dialing
the bind host (0.0.0.0) instead of 127.0.0.1, hijacking the host browser
when the container port was exposed.

* `hermes_cli/web_server.py::resolve_dashboard_ws_url()` now substitutes
  127.0.0.1 for any 0.0.0.0 bind host discovered via the existing
  `find_unused_port` / `get_listen_address` path. LAN IPs and explicit
  `DASHBOARD_WS_HOST` overrides pass through unchanged.
* Existing tests preserved (no regression on the explicit-bind case).

Tests in `tests/dashboard/test_ws_client_host.py` cover:
- Bind host 0.0.0.0 → ws URL uses 127.0.0.1
- Bind host 127.0.0.1 → ws URL uses 127.0.0.1 (no regression)
- Bind host 192.168.1.5 → ws URL preserves the LAN IP
- DASHBOARD_WS_HOST env override wins over auto-detection

AI-assisted fix by https://github.com/SquabbyZ/peaks-loop

(cherry picked from commit 5501dd38d6)

* chore(release): map SquabbyZ email for AUTHOR_MAP attribution (#59682)

---------

Co-authored-by: SquabbyZ <601709253@qq.com>
This commit is contained in:
Ben Barclay 2026-07-07 18:33:28 +10:00 committed by GitHub
parent 76979a0869
commit 4b9d9b205b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 360 additions and 2 deletions

View file

@ -12879,6 +12879,44 @@ def _resolve_chat_argv(
return list(argv), str(cwd) if cwd else None, env
# Hosts that mean "listen on every interface" — the server should bind to
# them, but an in-container client must NOT dial them: dialing 0.0.0.0
# resolves to "any local interface", which on most platforms routes through
# the kernel's wildcard stack and behind a forward proxy (HTTPS_PROXY with
# a NO_PROXY that doesn't list 0.0.0.0) gets MITM'd into a failed handshake
# (issue #58993). The fix is to use a loopback address for the client
# netloc while leaving the bind host alone.
_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"})
def _resolve_client_ws_host() -> Optional[str]:
"""Return the host the in-container WS client should dial.
Resolution order:
1. Explicit ``HERMES_DASHBOARD_WS_HOST`` env var wins always. Operators
running the dashboard behind a forward proxy can pin a routable host
(e.g. ``127.0.0.1``, the container's internal IP, or a sidecar DNS
name) and bypass auto-detection entirely.
2. The configured bind host if it's a wildcard (``0.0.0.0`` / ``::``),
substitute ``127.0.0.1`` since both the dashboard and its TUI child
run in the same container.
3. Any other bind host (loopback or LAN IP) preserved verbatim.
"""
explicit = os.environ.get("HERMES_DASHBOARD_WS_HOST", "").strip()
if explicit:
return explicit
host = getattr(app.state, "bound_host", None)
if not host:
return None
if host in _WILDCARD_HOSTS:
return "127.0.0.1"
return host
def _build_gateway_ws_url() -> Optional[str]:
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
@ -12890,7 +12928,7 @@ def _build_gateway_ws_url() -> Optional[str]:
the child reads this URL once at startup and reuses it on every reconnect,
and a 30s-TTL ticket can expire before a slow cold boot even dials.
"""
host = getattr(app.state, "bound_host", None)
host = _resolve_client_ws_host()
port = getattr(app.state, "bound_port", None)
if not host or not port:
@ -12957,7 +12995,7 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
Connections authenticated this way are recorded under the
``server-internal`` identity in the audit log.
"""
host = getattr(app.state, "bound_host", None)
host = _resolve_client_ws_host()
port = getattr(app.state, "bound_port", None)
if not host or not port: