Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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