diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 58e0d59908b30..942bb90e04d82 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3375,6 +3375,12 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool: ``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we don't want LAN hosts guessing tokens. + ``--insecure`` mode: non-loopback binding without an OAuth gate. + The operator has explicitly opted out of localhost-only mode, so + the loopback-only IP restriction is lifted here too. Without this, + ``--insecure`` is a no-op for WebSocket upgrades and every + non-loopback client gets 403 even though HTTP requests are allowed. + 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 +3391,8 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool: """ if getattr(app.state, "auth_required", False): return True + if getattr(app.state, "insecure", False): + return True client_host = ws.client.host if ws.client else "" if not client_host: return True @@ -4904,6 +4912,10 @@ def start_server( # PTY child uses to publish events to the dashboard sidebar. app.state.bound_host = host app.state.bound_port = port + # Expose allow_public on app.state so WebSocket security guards can read + # it at request time. Without this, every getattr(app.state, "insecure", + # False) call evaluates to False regardless of the flag passed by the user. + app.state.insecure = allow_public if open_browser: import webbrowser diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 9afef09d136d9..f8efd71a482c4 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -215,3 +215,48 @@ def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch): }, ): pass + + +class TestInsecureMode: + """When --insecure is active, _ws_client_is_allowed must lift the + loopback-only IP restriction so that non-loopback clients can connect. + start_server() is responsible for writing allow_public to app.state so + every runtime guard can read it.""" + + def test_non_loopback_client_rejected_without_insecure(self, monkeypatch): + """Baseline: a non-loopback peer is blocked in normal mode.""" + from unittest.mock import MagicMock + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "insecure", False, raising=False) + mock_ws = MagicMock() + mock_ws.client.host = "10.0.0.5" + assert not ws._ws_client_is_allowed(mock_ws) + + def test_non_loopback_client_allowed_with_insecure(self, monkeypatch): + """With --insecure, any client IP must pass _ws_client_is_allowed.""" + from unittest.mock import MagicMock + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "insecure", True, raising=False) + mock_ws = MagicMock() + mock_ws.client.host = "10.0.0.5" + assert ws._ws_client_is_allowed(mock_ws) + + def test_start_server_writes_insecure_to_app_state(self, monkeypatch): + """start_server() must persist allow_public to app.state.insecure so + WebSocket guards can read it at request time.""" + import uvicorn + + import hermes_cli.web_server as ws + + # uvicorn is lazily imported inside start_server(), so patch + # uvicorn.run on the uvicorn module itself rather than on the + # web_server module (where the name doesn't exist yet). + monkeypatch.setattr(uvicorn, "run", lambda *a, **kw: None) + + ws.start_server(host="127.0.0.1", port=9119, open_browser=False, allow_public=True) + assert ws.app.state.insecure is True + + ws.start_server(host="127.0.0.1", port=9119, open_browser=False, allow_public=False) + assert ws.app.state.insecure is False