diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 10f20dc35ad..1d83e7d7711 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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) diff --git a/tests/hermes_cli/test_dashboard_auth_ws_auth.py b/tests/hermes_cli/test_dashboard_auth_ws_auth.py index 0ebed6d9519..d16c7719d1a 100644 --- a/tests/hermes_cli/test_dashboard_auth_ws_auth.py +++ b/tests/hermes_cli/test_dashboard_auth_ws_auth.py @@ -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:. + 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")