Skip to content
Closed
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
68 changes: 50 additions & 18 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,43 +161,74 @@ 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
# defence can protect that mode; rely on operator network controls.
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

Expand Down
22 changes: 22 additions & 0 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions website/docs/user-guide/features/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down