diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 58e0d5990..70a87e196 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 diff --git a/tests/hermes_cli/test_dashboard_auth_ws_auth.py b/tests/hermes_cli/test_dashboard_auth_ws_auth.py index 0ebed6d95..e07e5e3c4 100644 --- a/tests/hermes_cli/test_dashboard_auth_ws_auth.py +++ b/tests/hermes_cli/test_dashboard_auth_ws_auth.py @@ -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