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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user