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
79 changes: 55 additions & 24 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,41 @@ def _require_token(request: Request) -> None:
"localhost", "127.0.0.1", "::1",
})

def _normalize_host_header(host_header: str) -> str:
"""Return the lowercase host portion of a Host header / netloc.

Accepts plain hostnames, host:port, bracketed IPv6, and bracketed
IPv6-with-port. Empty/malformed inputs normalize to an empty string.
"""
if not host_header:
return ""
h = host_header.strip()
if not h:
return ""
if h.startswith("["):
close = h.find("]")
if close != -1:
return h[1:close].lower()
return h.strip("[]").lower()
return (h.rsplit(":", 1)[0] if ":" in h else h).lower()


def _dashboard_allowed_host_aliases() -> frozenset[str]:
"""Trusted reverse-proxy host aliases for localhost dashboard binds.

Keeps the dashboard bound to 127.0.0.1 while allowing a local trusted
proxy such as Tailscale Serve to forward requests whose Host header is
the tailnet DNS name. Exact aliases only; no wildcards.
"""
raw = os.environ.get("HERMES_DASHBOARD_ALLOWED_HOSTS", "")
aliases = {
_normalize_host_header(part)
for part in raw.split(",")
if part.strip()
}
aliases.discard("")
return frozenset(aliases)


def should_require_auth(host: str, allow_public: bool = False) -> bool:
"""Return True iff the dashboard auth gate must be active.
Expand All @@ -405,38 +440,34 @@ def should_require_auth(host: str, allow_public: bool = False) -> bool:
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)
"""
if not host_header:
Lookup chain, in order:
1. Normalize the Host header to its bare host portion (strips ports and
IPv6 brackets) via ``_normalize_host_header``.
2. Accept any host when bound to 0.0.0.0 / :: — the operator explicitly
opted into all-interfaces and no Host-layer defence can protect that
mode.
3. Accept exact aliases from ``HERMES_DASHBOARD_ALLOWED_HOSTS`` — trusted
reverse-proxy hostnames (e.g. a Tailscale Serve tailnet name) that
forward to a loopback bind. Checked before the loopback/exact rules so
a localhost bind can still answer for its proxy alias.
4. Loopback bind: accept the loopback names (localhost, 127.0.0.1, ::1).
5. Explicit non-loopback bind: require an exact host match.
"""
host_only = _normalize_host_header(host_header)
if not host_only:
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()

# 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

# Trusted reverse-proxy aliases (exact match, no wildcards). Allows a
# localhost-bound dashboard to answer for a forwarded tailnet hostname.
if host_only in _dashboard_allowed_host_aliases():
return True

# Loopback bind: accept the loopback names
bound_lc = bound_host.lower()
if bound_lc in _LOOPBACK_HOST_VALUES:
Expand Down
13 changes: 13 additions & 0 deletions tests/hermes_cli/test_web_server_host_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ def test_loopback_bind_rejects_attacker_hostnames(self):
f"bound={bound} must reject attacker host={attacker!r}"
)

def test_loopback_bind_accepts_trusted_proxy_alias(self, monkeypatch):
"""Exact allowlisted aliases support trusted local reverse proxies."""
from hermes_cli.web_server import _is_accepted_host

monkeypatch.setenv(
"HERMES_DASHBOARD_ALLOWED_HOSTS",
"dashboard.tailnet.ts.net:443,[fd7a:115c:a1e0::1]:9119",
)

assert _is_accepted_host("dashboard.tailnet.ts.net", "127.0.0.1")
assert _is_accepted_host("[fd7a:115c:a1e0::1]", "localhost")
assert not _is_accepted_host("evil.tailnet.ts.net", "127.0.0.1")

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
17 changes: 16 additions & 1 deletion web/src/components/AuthWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ export function AuthWidget({ className }: AuthWidgetProps) {
const [error, setError] = useState<string | null>(null);

useEffect(() => {
// In loopback / --insecure mode the dashboard does not use the OAuth gate,
// so /api/auth/me is expected to return 401. Do not probe it: the shared
// fetchJSON 401 handler treats loopback 401s as possible stale dashboard
// tokens and reloads once to recover. Other successful page fetches can
// clear that guard, which turns this harmless auth probe into a visible
// reload/flicker loop behind trusted reverse proxies such as Tailscale
// Serve. The widget is documented to render nothing in this mode, so skip
// the request entirely unless the server says gated auth is active.
if (!window.__HERMES_AUTH_REQUIRED__) {
setHidden(true);
return;
}
let cancelled = false;
api
.getAuthMe()
Expand Down Expand Up @@ -125,7 +137,10 @@ export function AuthWidget({ className }: AuthWidgetProps) {
aria-label={`Logged in as ${label}`}
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
<span
className="truncate font-mono text-foreground/90"
title={me.user_id}
>
{label}
</span>
<span className="truncate text-muted-foreground/70">
Expand Down