mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
fix(dashboard): validate WebSocket Host and Origin
This commit is contained in:
parent
186bf25cb1
commit
2e66eefbc3
2 changed files with 106 additions and 4 deletions
|
|
@ -3305,6 +3305,39 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
|||
return True
|
||||
return client_host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Apply the dashboard Host/Origin guard to WebSocket upgrades.
|
||||
|
||||
FastAPI HTTP middleware does not run for WebSocket routes, so the
|
||||
DNS-rebinding Host check used for normal dashboard HTTP requests must be
|
||||
repeated here before accepting the upgrade. Browsers also send an Origin
|
||||
header on WebSocket handshakes; when present, require it to target the
|
||||
same bound dashboard host.
|
||||
"""
|
||||
bound_host = getattr(app.state, "bound_host", None)
|
||||
if not bound_host:
|
||||
return True
|
||||
|
||||
host_header = ws.headers.get("host", "")
|
||||
if not _is_accepted_host(host_header, bound_host):
|
||||
return False
|
||||
|
||||
origin = ws.headers.get("origin", "")
|
||||
if not origin:
|
||||
return True
|
||||
|
||||
parsed = urllib.parse.urlparse(origin)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
return False
|
||||
|
||||
return _is_accepted_host(parsed.netloc, bound_host)
|
||||
|
||||
|
||||
def _ws_request_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Return True when the WebSocket upgrade matches dashboard boundaries."""
|
||||
return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)
|
||||
|
||||
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
|
||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
||||
# the chat tab generates on mount; entries auto-evict when the last subscriber
|
||||
|
|
@ -3406,7 +3439,7 @@ async def pty_ws(ws: WebSocket) -> None:
|
|||
await ws.close(code=4401)
|
||||
return
|
||||
|
||||
if not _ws_client_is_allowed(ws):
|
||||
if not _ws_request_is_allowed(ws):
|
||||
await ws.close(code=4403)
|
||||
return
|
||||
|
||||
|
|
@ -3525,7 +3558,7 @@ async def gateway_ws(ws: WebSocket) -> None:
|
|||
await ws.close(code=4401)
|
||||
return
|
||||
|
||||
if not _ws_client_is_allowed(ws):
|
||||
if not _ws_request_is_allowed(ws):
|
||||
await ws.close(code=4403)
|
||||
return
|
||||
|
||||
|
|
@ -3557,7 +3590,7 @@ async def pub_ws(ws: WebSocket) -> None:
|
|||
await ws.close(code=4401)
|
||||
return
|
||||
|
||||
if not _ws_client_is_allowed(ws):
|
||||
if not _ws_request_is_allowed(ws):
|
||||
await ws.close(code=4403)
|
||||
return
|
||||
|
||||
|
|
@ -3586,7 +3619,7 @@ async def events_ws(ws: WebSocket) -> None:
|
|||
await ws.close(code=4401)
|
||||
return
|
||||
|
||||
if not _ws_client_is_allowed(ws):
|
||||
if not _ws_request_is_allowed(ws):
|
||||
await ws.close(code=4403)
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -146,3 +146,72 @@ class TestHostHeaderMiddleware:
|
|||
resp = client.get("/api/status")
|
||||
# Should get through to the status endpoint, not a 400
|
||||
assert resp.status_code != 400
|
||||
|
||||
|
||||
class TestWebSocketHostOriginGuard:
|
||||
"""WebSocket upgrades must enforce the same dashboard boundary as HTTP."""
|
||||
|
||||
def test_rebinding_websocket_host_is_rejected(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", "127.0.0.1", 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": "evil.example",
|
||||
"Origin": "http://evil.example",
|
||||
},
|
||||
):
|
||||
pass
|
||||
|
||||
assert exc.value.code == 4403
|
||||
|
||||
def test_rebinding_websocket_origin_is_rejected(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", "127.0.0.1", 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": "localhost:9119",
|
||||
"Origin": "http://evil.example",
|
||||
},
|
||||
):
|
||||
pass
|
||||
|
||||
assert exc.value.code == 4403
|
||||
|
||||
def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", 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": "localhost:9119",
|
||||
"Origin": "http://localhost:9119",
|
||||
},
|
||||
):
|
||||
pass
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue