From 8d48538a145e91a10ab538e0d3e8e3b29d25a574 Mon Sep 17 00:00:00 2001 From: martinramos002-bot <262243228+martinramos002-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:47:33 -0500 Subject: [PATCH] fix(dashboard): allow trusted proxy hosts on loopback --- hermes_cli/web_server.py | 54 ++++++++++++++++--- .../hermes_cli/test_web_server_host_header.py | 18 +++++++ .../docs/user-guide/features/web-dashboard.md | 17 ++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8995f18471f13..45c0fcc086e3d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -397,6 +397,35 @@ def _require_token(request: Request) -> None: }) +def _dashboard_allowed_hosts() -> set[str]: + """Extra trusted Host header values for loopback dashboards behind a proxy. + + The dashboard normally accepts only loopback Host headers when it binds to + 127.0.0.1. A trusted local reverse proxy can terminate HTTPS on an + external hostname and forward to 127.0.0.1 while preserving the original + Host header. Operators can opt in to those exact hostnames via + HERMES_DASHBOARD_ALLOWED_HOSTS without disabling the broader DNS-rebinding + protection. + """ + raw = os.getenv("HERMES_DASHBOARD_ALLOWED_HOSTS", "") + hosts: set[str] = set() + for item in raw.replace(";", ",").split(","): + value = item.strip().lower().rstrip(".") + if not value: + continue + if value.startswith("["): + close = value.find("]") + if close != -1: + value = value[1:close] + else: + value = value.strip("[]") + elif ":" in value: + value = value.rsplit(":", 1)[0] + if value: + hosts.add(value) + return hosts + + def should_require_auth(host: str, allow_public: bool = False) -> bool: """Return True iff the dashboard auth gate must be active. @@ -440,13 +469,27 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool: if h.startswith("["): # IPv6 bracketed — port (if any) follows "]:" close = h.find("]") - if close != -1: - host_only = h[1:close] # strip brackets - else: - host_only = h.strip("[]") + if close == -1: + return False + host_only = h[1:close] # strip brackets + suffix = h[close + 1:] + if ":" not in host_only: + return False + if suffix and not (suffix.startswith(":") and suffix[1:].isdigit()): + return False else: host_only = h.rsplit(":", 1)[0] if ":" in h else h - host_only = host_only.lower() + host_only = host_only.lower().rstrip(".") + bound_lc = bound_host.lower().rstrip(".") + + # Explicitly trusted proxy hostnames apply only to loopback binds. This is + # narrower than --insecure/0.0.0.0: only exact configured hostnames pass, + # and explicit non-loopback binds retain their exact-match behavior. + if ( + bound_lc in _LOOPBACK_HOST_VALUES + and host_only in _dashboard_allowed_hosts() + ): + return True # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer @@ -455,7 +498,6 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool: return True # Loopback bind: accept the loopback names - bound_lc = bound_host.lower() if bound_lc in _LOOPBACK_HOST_VALUES: return host_only in _LOOPBACK_HOST_VALUES diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 9afef09d136d9..170ead46310a4 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -83,6 +83,24 @@ def test_case_insensitive_comparison(self): assert _is_accepted_host("LOCALHOST", "127.0.0.1") assert _is_accepted_host("LocalHost:9119", "127.0.0.1") + def test_loopback_bind_accepts_explicit_proxy_hostnames(self, monkeypatch): + """Trusted reverse-proxy hostnames can be allowed without disabling + loopback DNS-rebinding protection for every other hostname.""" + from hermes_cli.web_server import _is_accepted_host + + monkeypatch.setenv( + "HERMES_DASHBOARD_ALLOWED_HOSTS", + "dashboard.example.test, secondary.example.test", + ) + + assert _is_accepted_host("dashboard.example.test", "127.0.0.1") + assert _is_accepted_host("dashboard.example.test:9443", "127.0.0.1") + assert _is_accepted_host("secondary.example.test.", "127.0.0.1") + assert not _is_accepted_host("evil.example", "127.0.0.1") + assert not _is_accepted_host("dashboard.example.test", "my-server.corp.net") + assert not _is_accepted_host("[dashboard.example.test]junk", "127.0.0.1") + assert not _is_accepted_host("[::1]:not-a-port", "127.0.0.1") + class TestHostHeaderMiddleware: """End-to-end test via the FastAPI app — verify the middleware diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index bcd43951f372b..ca93c0eca3182 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -41,6 +41,23 @@ hermes dashboard --host 0.0.0.0 hermes dashboard --no-open ``` +### Trusted reverse-proxy hostnames + +When the dashboard remains bound to loopback behind a trusted reverse proxy +that preserves the original `Host` header, allow the proxy's exact external +hostnames with a comma-separated environment variable: + +```bash +HERMES_DASHBOARD_ALLOWED_HOSTS=dashboard.example.test hermes dashboard --no-open +``` + +This setting only extends Host-header validation for the listed names. It does +not bind the dashboard to a public interface or allow other hostnames. A +loopback dashboard does not add authentication for external clients: the +reverse proxy must terminate TLS, enforce authentication and access controls, +and must not expose the loopback upstream directly. Avoid `--insecure` when a +secured loopback reverse proxy is sufficient. + ## Managing multiple profiles The dashboard is a **machine-level** management surface: one server manages