Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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