diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0e77b3d7a2531..d17fe917a9fa4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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) """ @@ -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: diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 983ee510ea287..6f6b811c07e62 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -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")