From 8523a9feaf0cadf3f0250bc0382cba3187183130 Mon Sep 17 00:00:00 2001 From: Jeffrey Quesnelle Date: Mon, 25 May 2026 01:10:18 -0400 Subject: [PATCH] fix(dashboard): allow file:// origin on loopback WS + diagnostic logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/web_server.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a3f455accc..3d59a8dad6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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: