From 577476fe163c869232fc5e478f10d37d3171af9e Mon Sep 17 00:00:00 2001 From: Dan Bennett Date: Tue, 2 Jun 2026 21:07:28 +0000 Subject: [PATCH] fix(dashboard): allow desktop websocket origins on remote binds --- hermes_cli/web_server.py | 16 +++-- .../hermes_cli/test_web_server_host_header.py | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fc868227a39c9..b79a1a9c5eaa2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6277,12 +6277,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 + # WebSocket handshake can carry a non-web Origin such as file://, null, + # app://, or hermes://. DNS-rebinding attacks originate from an + # http(s) site; they cannot forge a desktop-app origin, and the WS + # routes validate the dashboard session token before this guard. + # Allow these authenticated desktop clients on explicit non-loopback + # binds (for example trusted remote access over a private network) while + # still requiring the Host header to match the bound interface above. + # Public/gated binds use single-use tickets and should not accept + # non-web origins. + return not bool(getattr(app.state, "auth_required", False)) if not parsed.netloc: return False diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 9afef09d136d9..095930d6599bc 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -215,3 +215,75 @@ def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch): }, ): pass + + @pytest.mark.parametrize("origin", ["file://", "null", "app://hermes", "hermes://desktop"]) + def test_authenticated_desktop_origins_are_accepted_on_explicit_non_loopback_bind( + self, + monkeypatch, + origin, + ): + from fastapi.testclient import TestClient + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "100.64.0.10", raising=False) + monkeypatch.setattr(ws.app.state, "auth_required", False, raising=False) + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + + client = TestClient(ws.app) + url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test" + with client.websocket_connect( + url, + headers={ + "Host": "100.64.0.10:9119", + "Origin": origin, + }, + ): + pass + + def test_desktop_origin_still_requires_session_token(self, monkeypatch): + from fastapi.testclient import TestClient + from starlette.websockets import WebSocketDisconnect + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "100.64.0.10", raising=False) + monkeypatch.setattr(ws.app.state, "auth_required", False, raising=False) + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + + client = TestClient(ws.app) + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect( + "/api/events?token=&channel=security-test", + headers={ + "Host": "100.64.0.10:9119", + "Origin": "file://", + }, + ): + pass + + assert exc.value.code == 4401 + + def test_http_origin_still_rejected_on_explicit_non_loopback_bind(self, monkeypatch): + from fastapi.testclient import TestClient + from starlette.websockets import WebSocketDisconnect + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "100.64.0.10", raising=False) + monkeypatch.setattr(ws.app.state, "auth_required", False, raising=False) + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + + client = TestClient(ws.app) + url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test" + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect( + url, + headers={ + "Host": "100.64.0.10:9119", + "Origin": "http://evil.example", + }, + ): + pass + + assert exc.value.code == 4403