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:
SeaXen
2026-05-30 00:23:44 -07:00
committed by Teknium
parent 636ff636d7
commit e8076c1ebe
2 changed files with 43 additions and 1 deletions
@@ -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