diff --git a/hermes_cli/config.py b/hermes_cli/config.py index dd470bdbbf36..715740d9a19f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1074,6 +1074,12 @@ def _ensure_hermes_home_managed(home: Path): # Web dashboard settings "dashboard": { "theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose" + # Extra public hostnames the dashboard should accept in the HTTP Host + # header while still binding the origin to loopback. This is intended + # for reverse-proxy / Cloudflare Access deployments where the browser + # reaches https://dashboard.example.com but the origin listens on + # 127.0.0.1:9119. Keep empty for localhost-only operation. + "allowed_hosts": [], # Hide the token/cost analytics surfaces (Analytics page, token bars and # cost figures on the Models page) by default. The numbers shown there # are a local debug estimate: they only count successful main-agent diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4066b59910f7..abd5db61dab9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10164,8 +10164,28 @@ def cmd_dashboard(args): sys.exit(1) print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") + from hermes_cli.config import load_config from hermes_cli.web_server import start_server + def _split_hosts(value): + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + raw_values = value + else: + raw_values = [value] + hosts = [] + for raw in raw_values: + hosts.extend(str(raw).split(",")) + return [host.strip() for host in hosts if host.strip()] + + cfg = load_config() + dashboard_cfg = cfg.get("dashboard", {}) if isinstance(cfg, dict) else {} + allowed_hosts = [] + allowed_hosts.extend(_split_hosts(dashboard_cfg.get("allowed_hosts"))) + allowed_hosts.extend(_split_hosts(os.environ.get("HERMES_DASHBOARD_ALLOWED_HOSTS"))) + allowed_hosts.extend(_split_hosts(getattr(args, "allowed_host", None))) + embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1" start_server( host=args.host, @@ -10173,6 +10193,7 @@ def cmd_dashboard(args): open_browser=not args.no_open, allow_public=getattr(args, "insecure", False), embedded_chat=embedded_chat, + allowed_hosts=allowed_hosts, ) @@ -12904,6 +12925,17 @@ def cmd_acp(args): action="store_true", help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)", ) + dashboard_parser.add_argument( + "--allowed-host", + action="append", + default=[], + metavar="HOST", + help=( + "Additional exact Host header accepted by the dashboard. Use for " + "Cloudflare Access/reverse-proxy hostnames while binding origin " + "to 127.0.0.1. Can be repeated or comma-separated." + ), + ) dashboard_parser.add_argument( "--tui", action="store_true", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 7d28ce07617c..4d5e33dce426 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -159,23 +159,10 @@ def _require_token(request: Request) -> None: }) -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 - - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback, - no protection possible at this layer) - """ +def _host_header_name(host_header: str) -> str: + """Return the normalized hostname portion of a Host header.""" if not host_header: - return False - # Strip port suffix. IPv6 addresses use bracket notation: - # [::1] — no port - # [::1]:9119 — with port - # Plain hosts/v4: - # localhost:9119 - # 127.0.0.1:9119 + return "" h = host_header.strip() if h.startswith("["): # IPv6 bracketed — port (if any) follows "]:" @@ -186,7 +173,52 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool: host_only = h.strip("[]") else: host_only = h.rsplit(":", 1)[0] if ":" in h else h - host_only = host_only.lower() + return host_only.strip().lower().rstrip(".") + + +def _normalize_allowed_hosts(allowed_hosts: Optional[List[str]] = None) -> Tuple[str, ...]: + """Normalize configured extra dashboard Host values. + + Values are exact hostnames (optionally with a port suffix). Schemes, + paths, blanks and duplicates are ignored so config/env/CLI inputs remain + operator-friendly without widening the trust boundary. + """ + normalized: List[str] = [] + seen = set() + for value in allowed_hosts or []: + raw = str(value or "").strip() + if not raw or "*" in raw: + continue + if "://" in raw: + parsed = urllib.parse.urlparse(raw) + raw = parsed.netloc or parsed.path + else: + raw = raw.split("/", 1)[0] + host = _host_header_name(raw) + if host and host not in seen: + seen.add(host) + normalized.append(host) + return tuple(normalized) + + +def _is_accepted_host( + host_header: str, + bound_host: str, + allowed_hosts: Optional[List[str]] = None, +) -> 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 + - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback, + no protection possible at this layer) + - Extra exact hosts supplied by --allowed-host / dashboard.allowed_hosts + for loopback reverse-proxy deployments. + """ + host_only = _host_header_name(host_header) + if not host_only: + return False # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer @@ -194,6 +226,11 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool: if bound_host in {"0.0.0.0", "::"}: return True + # Explicit reverse-proxy names are exact matches only; wildcards are not + # supported because they would weaken the DNS-rebinding defence. + if host_only in _normalize_allowed_hosts(allowed_hosts): + return True + # Loopback bind: accept the loopback names bound_lc = bound_host.lower() if bound_lc in _LOOPBACK_HOST_VALUES: @@ -220,7 +257,8 @@ async def host_header_middleware(request: Request, call_next): bound_host = getattr(app.state, "bound_host", None) if bound_host: host_header = request.headers.get("host", "") - if not _is_accepted_host(host_header, bound_host): + allowed_hosts = getattr(app.state, "allowed_hosts", ()) + if not _is_accepted_host(host_header, bound_host, allowed_hosts): return JSONResponse( status_code=400, content={ @@ -4518,6 +4556,7 @@ def start_server( allow_public: bool = False, *, embedded_chat: bool = False, + allowed_hosts: Optional[List[str]] = None, ): """Start the web UI server.""" import uvicorn @@ -4544,6 +4583,7 @@ def start_server( # PTY child uses to publish events to the dashboard sidebar. app.state.bound_host = host app.state.bound_port = port + app.state.allowed_hosts = _normalize_allowed_hosts(allowed_hosts) 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 966127b05ce6..3ac5bfe9bae1 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -54,6 +54,22 @@ def test_loopback_bind_rejects_attacker_hostnames(self): f"bound={bound} must reject attacker host={attacker!r}" ) + def test_loopback_bind_accepts_configured_proxy_hostname(self): + from hermes_cli.web_server import _is_accepted_host + + allowed = ["audit-kanban.scheel.no"] + assert _is_accepted_host("audit-kanban.scheel.no", "127.0.0.1", allowed) + assert _is_accepted_host("AUDIT-KANBAN.SCHEEL.NO:443", "127.0.0.1", allowed) + assert not _is_accepted_host("evil.example", "127.0.0.1", allowed) + + def test_allowed_hosts_normalize_url_values_without_wildcards(self): + from hermes_cli.web_server import _is_accepted_host + + allowed = ["https://audit-kanban.scheel.no/some/path", "*.scheel.no"] + assert _is_accepted_host("audit-kanban.scheel.no", "127.0.0.1", allowed) + assert not _is_accepted_host("tenant.scheel.no", "127.0.0.1", allowed) + assert not _is_accepted_host("*.scheel.no", "127.0.0.1", allowed) + def test_zero_zero_bind_accepts_anything(self): """0.0.0.0 means operator explicitly opted into all-interfaces (requires --insecure). No Host-layer defence is possible — rely @@ -118,9 +134,10 @@ def test_legit_loopback_request_accepted(self): client = TestClient(app) # /api/status is in _PUBLIC_API_PATHS — passes auth — so the # only thing that can reject is the host header middleware + app.state.allowed_hosts = ("audit-kanban.scheel.no",) resp = client.get( "/api/status", - headers={"Host": "localhost:9119"}, + headers={"Host": "audit-kanban.scheel.no"}, ) # Either 200 (endpoint served) or some other non-400 — # just not the host-rejection 400 @@ -130,6 +147,8 @@ def test_legit_loopback_request_accepted(self): finally: if hasattr(app.state, "bound_host"): del app.state.bound_host + if hasattr(app.state, "allowed_hosts"): + del app.state.allowed_hosts def test_no_bound_host_skips_validation(self): """If app.state.bound_host isn't set (e.g. running under test