diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 11ceefc9d3a..e5333928d87 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6605,13 +6605,16 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: parsed = urllib.parse.urlparse(origin) 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 + # Packaged Electron loads the desktop renderer over a non-web origin + # such as file:// or null. This helper is called only after _ws_auth_ok + # has accepted the WS credential; in non-gated mode that credential is + # the legacy dashboard session token, including for explicit Tailscale / + # LAN binds opened with --insecure. Real DNS-rebinding attacks arrive + # from http(s) origins and still have to match the bound host below. + # + # OAuth-gated public dashboards authenticate with cookies/tickets and + # have no legitimate file:// client, so keep them strict. + return not getattr(app.state, "auth_required", False) if not parsed.netloc: return False diff --git a/tests/hermes_cli/test_dashboard_auth_ws_auth.py b/tests/hermes_cli/test_dashboard_auth_ws_auth.py index ff432e64c5b..64dc925b2cb 100644 --- a/tests/hermes_cli/test_dashboard_auth_ws_auth.py +++ b/tests/hermes_cli/test_dashboard_auth_ws_auth.py @@ -381,8 +381,9 @@ class TestWsHostOriginGuardOrigins: 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. + loopback or explicit non-loopback insecure bind these non-web origins are + trusted because the session token is the real gate. OAuth-gated public + binds keep rejecting them. """ def _ws(self, *, origin, host): @@ -413,8 +414,29 @@ class TestWsHostOriginGuardOrigins: 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_explicit_non_loopback_file_origin_allowed(self, insecure_explicit_host_app): + """Packaged Hermes Desktop also uses file:// when connecting to a + Tailscale/LAN dashboard bind. + + The WebSocket route calls _ws_auth_ok before this guard, so in + non-gated mode the legacy session token remains the auth boundary. + """ + ws = self._ws(origin="file://", host="100.64.0.10:9119") + assert web_server._ws_host_origin_is_allowed(ws) is True + + def test_explicit_non_loopback_null_origin_allowed(self, insecure_explicit_host_app): + ws = self._ws(origin="null", host="100.64.0.10:9119") + assert web_server._ws_host_origin_is_allowed(ws) is True + + def test_explicit_non_loopback_cross_site_http_origin_rejected( + self, insecure_explicit_host_app + ): + ws = self._ws(origin="http://localhost:9119", host="100.64.0.10:9119") + 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. + # OAuth-gated public dashboards authenticate with cookies/tickets, + # not the legacy desktop session token. ws = self._ws(origin="file://", host="fly-app.fly.dev") assert web_server._ws_host_origin_is_allowed(ws) is False