From bd8e91f90102489b0d50e5e0de7b32286b3e5f57 Mon Sep 17 00:00:00 2001 From: Leonard Sellem Date: Tue, 2 Jun 2026 15:02:24 +0200 Subject: [PATCH 1/2] fix(dashboard): allow desktop websocket origins on remote binds --- hermes_cli/web_server.py | 17 ++++++----- .../hermes_cli/test_dashboard_auth_ws_auth.py | 28 +++++++++++++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 7842465a525c..9ecb2c0709c6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6603,13 +6603,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 ff432e64c5b0..64dc925b2cb1 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 @@ def test_loopback_cross_site_http_origin_rejected(self, loopback_app): 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 From 0b8208ebe76d7942c44301a973da71268ff1b90f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:27:34 -0700 Subject: [PATCH 2/2] chore: add leonardsellem to AUTHOR_MAP for PR #37405 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index be4749a37a56..80240f2e5f05 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1425,6 +1425,7 @@ "2663402852@qq.com": "x1am1", # PR #35098 (chown root-owned top-level HERMES_HOME state files) "nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser) "wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun) + "leonard@sellem.me": "leonardsellem", # PR #37405 (desktop WS origin guard on remote/Tailscale binds) }