mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
fix(dashboard): allow packaged desktop file:// origin on loopback WS
The packaged Electron desktop loads its renderer over file://, so its /api/ws handshake carries Origin: file:// (or null). The DNS-rebinding WebSocket Origin guard only accepted http(s) origins matching the bound host, so it rejected the desktop's own renderer with 4403 -> "Could not connect to Hermes gateway" on macOS. A browser DNS-rebinding attacker can only ever present an http(s) origin (the site hosting the malicious page); it cannot forge file://, null, or a custom app scheme AND hold the loopback session token. So on loopback binds we now trust non-web origins -- the token in _ws_auth_ok remains the real authenticator. Public/gated binds still reject them, and cross-site http(s) origins are still rejected everywhere.
This commit is contained in:
parent
5aade4bc57
commit
861b63228c
2 changed files with 54 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:<port>.
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue