fix(dashboard): validate WebSocket Host and Origin
This commit is contained in:
@ -3305,6 +3305,39 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
|||||||
return True
|
return True
|
||||||
return client_host in _LOOPBACK_HOSTS
|
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)
|
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
|
||||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
# 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
|
# 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)
|
await ws.close(code=4401)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not _ws_client_is_allowed(ws):
|
if not _ws_request_is_allowed(ws):
|
||||||
await ws.close(code=4403)
|
await ws.close(code=4403)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -3525,7 +3558,7 @@ async def gateway_ws(ws: WebSocket) -> None:
|
|||||||
await ws.close(code=4401)
|
await ws.close(code=4401)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not _ws_client_is_allowed(ws):
|
if not _ws_request_is_allowed(ws):
|
||||||
await ws.close(code=4403)
|
await ws.close(code=4403)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -3557,7 +3590,7 @@ async def pub_ws(ws: WebSocket) -> None:
|
|||||||
await ws.close(code=4401)
|
await ws.close(code=4401)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not _ws_client_is_allowed(ws):
|
if not _ws_request_is_allowed(ws):
|
||||||
await ws.close(code=4403)
|
await ws.close(code=4403)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -3586,7 +3619,7 @@ async def events_ws(ws: WebSocket) -> None:
|
|||||||
await ws.close(code=4401)
|
await ws.close(code=4401)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not _ws_client_is_allowed(ws):
|
if not _ws_request_is_allowed(ws):
|
||||||
await ws.close(code=4403)
|
await ws.close(code=4403)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@ -146,3 +146,72 @@ class TestHostHeaderMiddleware:
|
|||||||
resp = client.get("/api/status")
|
resp = client.get("/api/status")
|
||||||
# Should get through to the status endpoint, not a 400
|
# Should get through to the status endpoint, not a 400
|
||||||
assert resp.status_code != 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
|
||||||
|
|||||||
Reference in New Issue
Block a user