fix(dashboard): allow packaged desktop file:// origin on loopback WS

The packaged Electron desktop loads its renderer over file://, so its
/api/ws handshake carries Origin: file:// (or null). The DNS-rebinding
WebSocket Origin guard only accepted http(s) origins matching the bound
host, so it rejected the desktop's own renderer with 4403 -> "Could not
connect to Hermes gateway" on macOS.

A browser DNS-rebinding attacker can only ever present an http(s) origin
(the site hosting the malicious page); it cannot forge file://, null, or
a custom app scheme AND hold the loopback session token. So on loopback
binds we now trust non-web origins -- the token in _ws_auth_ok remains
the real authenticator. Public/gated binds still reject them, and
cross-site http(s) origins are still rejected everywhere.
This commit is contained in:
Brooklyn Nicholson 2026-05-30 01:40:35 -05:00
parent 5aade4bc57
commit 861b63228c
2 changed files with 54 additions and 1 deletions

View file

@ -4210,7 +4210,16 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
return True
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
if parsed.scheme not in {"http", "https"}:
# Packaged Electron loads the desktop renderer over file://, so its
# WebSocket handshake carries a non-web Origin such as file:// or null.
# DNS-rebinding attacks originate from an http(s) site; they cannot
# forge a file:// origin and still hold the loopback session token.
# Public/gated binds have no legitimate non-web client, so keep
# rejecting these origins there.
return bound_host.lower() in _LOOPBACK_HOST_VALUES
if not parsed.netloc:
return False
return _is_accepted_host(parsed.netloc, bound_host)

View file

@ -290,6 +290,50 @@ class TestWsRequestIsAllowedGated:
assert web_server._ws_request_is_allowed(ws) is False
class TestWsHostOriginGuardOrigins:
"""The WS Origin guard must let the packaged desktop shell connect.
Electron loads the packaged renderer over ``file://``, so its WebSocket
handshake carries ``Origin: file://`` (or the opaque ``null``). The
DNS-rebinding guard only needs to block cross-site http(s) origins. On a
loopback bind these non-web origins are trusted because the session token
is the real gate. Public/gated binds keep rejecting them.
"""
def _ws(self, *, origin, host):
ws = _fake_ws(query={}, path="/api/ws")
ws.headers = {"host": host, "origin": origin}
return ws
def test_loopback_file_origin_allowed(self, loopback_app):
ws = self._ws(origin="file://", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_null_origin_allowed(self, loopback_app):
ws = self._ws(origin="null", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_app_scheme_origin_allowed(self, loopback_app):
ws = self._ws(origin="app://hermes", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_matching_http_origin_allowed(self, loopback_app):
# The dev renderer (vite) loads over http://127.0.0.1:<port>.
ws = self._ws(origin="http://127.0.0.1:5174", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_cross_site_http_origin_rejected(self, loopback_app):
# DNS-rebinding / cross-site: a real web attacker can only present an
# http(s) origin, and that must still be rejected.
ws = self._ws(origin="http://evil.test", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is False
def test_gated_file_origin_rejected(self, gated_app):
# A public/gated bind has no legitimate file:// client.
ws = self._ws(origin="file://", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is False
class TestSidecarUrl:
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_sidecar_url("ch-1")