Skip to content
Open
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
6 changes: 6 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10164,15 +10164,36 @@ 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")))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid adding a public HERMES_* env var for this non-secret behavior; AGENTS.md says behavioral settings should go through config.yaml rather than new user-facing environment variables.

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,
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
embedded_chat=embedded_chat,
allowed_hosts=allowed_hosts,
)


Expand Down Expand Up @@ -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",
Expand Down
76 changes: 58 additions & 18 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "]:"
Expand All @@ -186,14 +173,64 @@ 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
# defence can protect that mode; rely on operator network controls.
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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allowlist check needs to live inside the loopback-bind branch. As written, a server explicitly bound to a non-loopback hostname will also accept any configured proxy hostname, weakening the exact-match rule for that mode.

return True

# Loopback bind: accept the loopback names
bound_lc = bound_host.lower()
if bound_lc in _LOOPBACK_HOST_VALUES:
Expand All @@ -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={
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 20 additions & 1 deletion tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down