From 2f7d4f6c4f57c6e8dfa52af13954f7fadf165986 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 23 May 2026 16:26:09 +0000 Subject: [PATCH] fix(dashboard): allow explicit host allowlist --- hermes_cli/web_server.py | 68 ++++++++++++++----- .../hermes_cli/test_web_server_host_header.py | 22 ++++++ .../docs/user-guide/features/web-dashboard.md | 2 + 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d5d319dda095..9f3182bde616 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -23,6 +23,7 @@ import urllib.parse import urllib.request from pathlib import Path +from urllib.parse import urlsplit from typing import Any, Dict, List, Optional, Tuple import yaml @@ -160,34 +161,62 @@ def _require_token(request: Request) -> None: }) +def _normalize_host_value(value: str) -> str: + """Normalize a host value for comparison. + + Handles raw Host headers and allowlist entries by stripping scheme, path, + query, fragment, whitespace, surrounding IPv6 brackets, port suffixes, + and a trailing dot. Comparison is case-insensitive. + """ + v = value.strip().lower() + if not v: + return "" + + if "://" in v: + v = urlsplit(v).hostname or "" + else: + v = v.split("/", 1)[0] + v = v.split("?", 1)[0].split("#", 1)[0] + + if v.startswith("[") and "]" in v: + v = v[1 : v.find("]")] + elif v.count(":") == 1: + v = v.rsplit(":", 1)[0] + + return v.rstrip(".") + + +def _configured_dashboard_allowed_hosts() -> set[str]: + """Optional explicit allowlist for dashboard host headers. + + This is primarily used when the dashboard is kept bound to localhost and + exposed via a proxy/Serve endpoint, where the incoming Host header is a + public hostname rather than the loopback bind target. + """ + raw = os.environ.get("HERMES_DASHBOARD_ALLOWED_HOSTS", "") + if not raw: + return set() + return { + normalized + for item in raw.split(",") + if (normalized := _normalize_host_value(item)) + } + + 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 allowlist entries from HERMES_DASHBOARD_ALLOWED_HOSTS - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback, no protection possible at this layer) """ 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 - h = host_header.strip() - 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("[]") - else: - host_only = h.rsplit(":", 1)[0] if ":" in h else h - host_only = host_only.lower() + host_only = _normalize_host_value(host_header) + allowed_hosts = _configured_dashboard_allowed_hosts() # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer @@ -195,8 +224,11 @@ 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 allowed_hosts: + return True + # Loopback bind: accept the loopback names - bound_lc = bound_host.lower() + bound_lc = _normalize_host_value(bound_host) 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 966127b05ce6..828135117344 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -83,6 +83,28 @@ 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_explicit_allowed_hosts_override_loopback_only_bind(self, monkeypatch): + from hermes_cli import web_server + + monkeypatch.setenv( + "HERMES_DASHBOARD_ALLOWED_HOSTS", + "https://dashboard.example.com:443, dashboard.example.com., https://dashboard.example.com/path", + ) + assert web_server._is_accepted_host("dashboard.example.com", "127.0.0.1") + assert web_server._is_accepted_host("https://dashboard.example.com:443", "127.0.0.1") + + def test_explicit_allowed_hosts_do_not_widen_arbitrary_hosts(self, monkeypatch): + from hermes_cli import web_server + + monkeypatch.setenv("HERMES_DASHBOARD_ALLOWED_HOSTS", "dashboard.example.com") + assert not web_server._is_accepted_host("evil.example.com", "127.0.0.1") + + def test_normalizes_bound_host_and_host_header(self): + from hermes_cli.web_server import _is_accepted_host + + assert _is_accepted_host("LOCALHOST:9119", "LocalHost") + assert _is_accepted_host("[::1]:9119", "[::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 d7201cbbe08c..20295ebecb3f 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -185,6 +185,8 @@ Browse, search, and toggle skills and toolsets. Skills are loaded from `~/.herme The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. The dashboard has no authentication of its own. ::: +If you keep the dashboard bound to localhost and expose it through a trusted proxy or Tailscale Serve HTTPS endpoint, set `HERMES_DASHBOARD_ALLOWED_HOSTS` to a comma-separated allowlist of public hostnames that should be accepted by the dashboard's Host-header validation. Each entry is normalized before comparison (scheme/path/port removed, lowercase, trailing dot stripped), and matching remains exact after normalization. + ## `/reload` Slash Command The dashboard PR also adds a `/reload` slash command to the interactive CLI. After changing API keys via the web dashboard (or by editing `.env` directly), use `/reload` in an active CLI session to pick up the changes without restarting: