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
19 changes: 19 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,28 @@ def should_require_auth(host: str, allow_public: bool) -> bool:
return (host not in _LOOPBACK_HOST_VALUES) and (not allow_public)


def _dashboard_extra_host_values() -> frozenset[str]:
"""Return explicitly allowed reverse-proxy hostnames for the dashboard.

The default loopback bind rejects non-loopback Host/Origin values to block
DNS rebinding. A local reverse proxy or Cloudflare Tunnel can still be safe
when the operator pins the public hostname here; keep this opt-in narrow.
"""
raw = os.getenv("HERMES_DASHBOARD_ALLOWED_HOSTS", "")
return frozenset(
item.strip().lower().rsplit(":", 1)[0]
for item in raw.split(",")
if item.strip()
)


def _is_accepted_host(host_header: str, bound_host: str) -> bool:
"""True if the Host header targets the interface we bound to.

Accepts:
- Exact bound host (with or without port suffix)
- Loopback aliases when bound to loopback
- Explicit operator allowlist entries for reverse-proxied loopback binds
- Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback,
no protection possible at this layer)
"""
Expand Down Expand Up @@ -339,6 +355,9 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool:
if bound_host in {"0.0.0.0", "::"}:
return True

if host_only in _dashboard_extra_host_values():
return True

# Loopback bind: accept the loopback names
bound_lc = bound_host.lower()
if bound_lc in _LOOPBACK_HOST_VALUES:
Expand Down
13 changes: 13 additions & 0 deletions tests/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,16 @@ def test_start_server_enables_ws_ping_for_half_open_detection(monkeypatch):

assert captured["ws_ping_interval"] == 20.0
assert captured["ws_ping_timeout"] == 20.0


def test_loopback_dashboard_rejects_unconfigured_reverse_proxy_host(monkeypatch):
monkeypatch.delenv("HERMES_DASHBOARD_ALLOWED_HOSTS", raising=False)

assert not web_server._is_accepted_host("dashboard.example.com", "127.0.0.1")


def test_loopback_dashboard_allows_configured_reverse_proxy_host(monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_ALLOWED_HOSTS", "dashboard.example.com")

assert web_server._is_accepted_host("dashboard.example.com", "127.0.0.1")
assert web_server._is_accepted_host("dashboard.example.com:443", "127.0.0.1")