fix(dashboard): allow file:// origin on loopback WS + diagnostic logging

Upstream commit 2e66eefbc ("fix(dashboard): validate WebSocket Host
and Origin") added a WebSocket Host/Origin guard to block DNS
rebinding against the dashboard.  The guard rejects any Origin whose
scheme is not http/https or whose netloc is empty — which includes
Electron's renderer Origin: file:// when the desktop app loads its
bundle from disk in production mode.

That makes the bb/gui Electron desktop unable to open the gateway
WebSocket against the embedded backend on Windows / macOS prod
builds.  The renderer reports "Desktop boot failed" and the backend
logs:

  WARNING hermes_cli.web_server: gateway-ws reject
      peer=127.0.0.1:NNNN reason=non_loopback_or_bad_origin
      bound_host=127.0.0.1 close_code=4403

DNS-rebinding requires a DNS-resolvable hostname; file:// has no
host component and therefore cannot be the attack vector this guard
exists to block.  When bound to a loopback interface (127.0.0.1 /
::1 / localhost), accept file:// origins so desktop wrappers can
attach.  Non-loopback binds (operator opted into network exposure)
keep rejecting file:// — the loose policy doesn't apply.

Also adds per-reason diagnostic logging in
_ws_host_origin_is_allowed, so future ws-guard rejections name the
specific clause that fired (bad_host / bad_origin_scheme /
origin_host_mismatch) instead of the opaque
"non_loopback_or_bad_origin" surfaced at the call site.

Verified against tests/hermes_cli/test_web_server_host_header.py
(all 11 upstream tests still pass) and hand-tested by opening the
bb/gui Electron desktop dev build against the patched backend.
This commit is contained in:
Jeffrey Quesnelle
2026-05-25 01:10:18 -04:00
parent e1338265c1
commit 8523a9feaf
+31 -1
View File
@@ -4557,6 +4557,14 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
repeated here before accepting the upgrade. Browsers also send an Origin
header on WebSocket handshakes; when present, require it to target the
same bound dashboard host.
Special case: when bound to a loopback interface (127.0.0.1 / ::1 /
localhost), accept ``Origin: file://`` from desktop wrappers (Electron,
similar) that load the renderer from disk. DNS-rebinding requires a
DNS-resolved hostname; ``file://`` carries no host and therefore cannot
be the rebinding vector this guard exists to block. Non-loopback binds
keep rejecting ``file://`` — the operator chose to expose the dashboard
to the network, so the looser policy doesn't apply.
"""
bound_host = getattr(app.state, "bound_host", None)
if not bound_host:
@@ -4564,6 +4572,10 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
host_header = ws.headers.get("host", "")
if not _is_accepted_host(host_header, bound_host):
_log.warning(
"ws-guard reject reason=bad_host host_header=%r bound_host=%r",
host_header, bound_host,
)
return False
origin = ws.headers.get("origin", "")
@@ -4571,10 +4583,28 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
return True
parsed = urllib.parse.urlparse(origin)
# Loopback-bind + file:// carve-out: see docstring.
if (
parsed.scheme == "file"
and bound_host.lower() in _LOOPBACK_HOST_VALUES
):
return True
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
_log.warning(
"ws-guard reject reason=bad_origin_scheme origin=%r bound_host=%r",
origin, bound_host,
)
return False
return _is_accepted_host(parsed.netloc, bound_host)
if not _is_accepted_host(parsed.netloc, bound_host):
_log.warning(
"ws-guard reject reason=origin_host_mismatch origin=%r netloc=%r bound_host=%r",
origin, parsed.netloc, bound_host,
)
return False
return True
def _ws_request_is_allowed(ws: "WebSocket") -> bool: