fix(dashboard): allow chat websockets on insecure public bind
Allow non-loopback websocket peers when the dashboard is explicitly exposed with --host 0.0.0.0/:: and --insecure. This fixes the failure mode where /chat rendered over LAN but /api/ws and /api/events were rejected with HTTP 403, leaving the embedded TUI chat disconnected. Add regression coverage for the insecure public bind case in the dashboard websocket auth tests.
This commit is contained in:
@ -3371,10 +3371,15 @@ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Check if the WebSocket client IP is acceptable.
|
||||
|
||||
Loopback mode: only loopback clients allowed — the legacy
|
||||
Loopback bind: only loopback clients allowed — the legacy
|
||||
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
|
||||
don't want LAN hosts guessing tokens.
|
||||
|
||||
All-interfaces insecure bind (``--host 0.0.0.0 --insecure`` or
|
||||
``--host :: --insecure``): allow any peer. The operator explicitly
|
||||
opted into LAN/public exposure in this mode, so the loopback-only peer
|
||||
restriction should not apply.
|
||||
|
||||
Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True``
|
||||
(enabled when the OAuth gate is active so cookies can pick up
|
||||
``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the
|
||||
@ -3385,6 +3390,9 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""
|
||||
if getattr(app.state, "auth_required", False):
|
||||
return True
|
||||
bound_host = getattr(app.state, "bound_host", "")
|
||||
if bound_host in {"0.0.0.0", "::"}:
|
||||
return True
|
||||
client_host = ws.client.host if ws.client else ""
|
||||
if not client_host:
|
||||
return True
|
||||
|
||||
@ -80,6 +80,25 @@ def loopback_app():
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def insecure_public_app():
|
||||
"""web_server.app configured for all-interfaces insecure mode."""
|
||||
_reset_for_tests()
|
||||
clear_providers()
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "0.0.0.0"
|
||||
web_server.app.state.bound_port = 9120
|
||||
web_server.app.state.auth_required = False
|
||||
client = TestClient(web_server.app, base_url="http://192.168.0.222:9120")
|
||||
yield client
|
||||
_reset_for_tests()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
def _logged_in(client: TestClient) -> None:
|
||||
"""Drive the stub OAuth round trip so the client holds session cookies."""
|
||||
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
|
||||
@ -281,6 +300,21 @@ class TestWsRequestIsAllowedGated:
|
||||
ws.headers = {"host": "127.0.0.1:8080"}
|
||||
assert web_server._ws_request_is_allowed(ws) is True
|
||||
|
||||
def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app):
|
||||
"""`--host 0.0.0.0 --insecure` is an explicit LAN/public opt-in.
|
||||
|
||||
Regression coverage for the dashboard `/chat` breakage where the
|
||||
HTML shell loaded on 9120 but every WebSocket upgrade was rejected
|
||||
with 403 because the loopback-only peer guard still ran even though
|
||||
the operator intentionally exposed the dashboard on all interfaces.
|
||||
"""
|
||||
ws = _fake_ws(query={}, client_host="192.168.0.55")
|
||||
ws.headers = {
|
||||
"host": "192.168.0.222:9120",
|
||||
"origin": "http://192.168.0.222:9120",
|
||||
}
|
||||
assert web_server._ws_request_is_allowed(ws) is True
|
||||
|
||||
def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app):
|
||||
"""Bypassing the peer-IP check must not bypass the DNS-rebinding
|
||||
Host header guard — that one still protects against attacker
|
||||
|
||||
Reference in New Issue
Block a user