Summary
The hermes dashboard command binds to 127.0.0.1:9119 by default and enforces a host-header validation middleware (defence against DNS rebinding, GHSA-ppp5-vxwm-4cf7). When the dashboard is placed behind a reverse proxy — including Tailscale Serve, nginx, Caddy, or any similar TLS terminator — the proxy forwards the original Host header from the client (e.g. kevins-mini.tail79ec1f.ts.net) to the local server. Because that hostname doesn't match the loopback allowlist, every request is rejected with:
{"detail": "Invalid Host header. Dashboard requests must use the hostname the server was bound to."}
This makes it impossible to use the dashboard remotely via Tailscale Serve (the recommended way to expose local services on a tailnet with automatic TLS) or any other reverse proxy, without resorting to --insecure --host 0.0.0.0 which exposes the dashboard on all interfaces.
Use Case
Tailscale Serve is a common pattern for securely exposing local services to a private tailnet:
tailscale serve --bg --https=443 9119
This proxies https://<machine>.tail<id>.ts.net → http://127.0.0.1:9119. Tailscale handles TLS automatically. The dashboard stays loopback-bound (never exposed to the LAN), but the host header the local server sees is the MagicDNS hostname, not 127.0.0.1.
The same pattern applies to any TLS-terminating reverse proxy (nginx proxy_pass, Caddy reverse_proxy, Traefik, etc.).
Proposed Fix
Add an --allowed-hosts CLI flag that accepts a comma-separated list of additional hostnames to whitelist in the host-header middleware. The DNS rebinding protection is fully preserved — only explicitly listed names are added to the allowlist; no wildcard or bypass logic is introduced.
hermes_cli/web_server.py
1. _is_accepted_host — add extra_allowed parameter:
def _is_accepted_host(
host_header: str,
bound_host: str,
extra_allowed: Optional[frozenset] = None,
) -> bool:
# ... existing port-stripping logic unchanged ...
if bound_host in {"0.0.0.0", "::"}:
return True
# Check explicitly allowed hostnames before the bound-host rule.
# Enables reverse-proxy / Tailscale Serve access without --insecure.
if extra_allowed and host_only in extra_allowed:
return True
bound_lc = bound_host.lower()
if bound_lc in _LOOPBACK_HOST_VALUES:
return host_only in _LOOPBACK_HOST_VALUES
return host_only == bound_lc
2. host_header_middleware — read app.state.extra_allowed_hosts:
bound_host = getattr(app.state, "bound_host", None)
if bound_host:
host_header = request.headers.get("host", "")
extra_allowed = getattr(app.state, "extra_allowed_hosts", None)
if not _is_accepted_host(host_header, bound_host, extra_allowed):
return JSONResponse(status_code=400, content={"detail": "..."})
3. _ws_host_origin_is_allowed — same for WebSocket upgrades:
extra_allowed = getattr(app.state, "extra_allowed_hosts", None)
if not _is_accepted_host(host_header, bound_host, extra_allowed):
return False
# ...
return _is_accepted_host(parsed.netloc, bound_host, extra_allowed)
4. start_server — new allowed_hosts keyword argument:
def start_server(
host: str = "127.0.0.1",
port: int = 9119,
open_browser: bool = True,
allow_public: bool = False,
*,
embedded_chat: bool = False,
allowed_hosts: Optional[List[str]] = None,
):
# ...
app.state.bound_host = host
app.state.bound_port = port
app.state.extra_allowed_hosts = (
frozenset(h.lower().strip() for h in allowed_hosts if h.strip())
if allowed_hosts
else None
)
hermes_cli/main.py
Add --allowed-hosts to the dashboard argument parser:
dashboard_parser.add_argument(
"--allowed-hosts",
dest="allowed_hosts",
default="",
help=(
"Comma-separated list of additional hostnames to accept in the Host header "
"(e.g. your Tailscale MagicDNS name). Use with --host <ip> --insecure "
"to expose the dashboard only on a specific interface."
),
)
Pass it through in the cmd_dashboard handler:
allowed_hosts_raw = getattr(args, "allowed_hosts", "") or ""
allowed_hosts = [h for h in (h.strip() for h in allowed_hosts_raw.split(",")) if h]
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 or None,
)
Usage After Fix
Tailscale Serve (recommended — stays loopback-bound, automatic TLS):
# One-time Tailscale Serve setup (if not already configured):
tailscale serve --bg --https=443 9119
# Start dashboard — no --insecure needed, server stays on 127.0.0.1
hermes dashboard --allowed-hosts your-machine.tail12345.ts.net --no-open
Explicit non-loopback bind (for nginx/Caddy on LAN):
hermes dashboard --host 192.168.1.x --allowed-hosts dashboard.internal.example.com --insecure
Security Considerations
- The existing DNS rebinding protection (GHSA-ppp5-vxwm-4cf7) is fully preserved.
extra_allowed is an explicit opt-in list with no wildcard matching.
- When used with Tailscale Serve, the server remains bound to
127.0.0.1 — it is never exposed on any network interface. --insecure is not required.
- Operators are responsible for ensuring listed hostnames are under their control. The flag is intentionally not accepting wildcards or regex patterns.
Alternatives Considered
--host 0.0.0.0 --insecure: Works but exposes the dashboard on all interfaces (LAN, WiFi, etc.), not just the Tailscale interface. Unnecessary risk.
- Binding to the Tailscale IP directly (
--host 100.x.x.x): Limits exposure to the Tailscale interface, but the exact-match host check still rejects the MagicDNS hostname. Requires --allowed-hosts anyway.
- No code change, document workaround: Leaves users with no good option for reverse-proxy setups without
--insecure.
Summary
The
hermes dashboardcommand binds to127.0.0.1:9119by default and enforces a host-header validation middleware (defence against DNS rebinding, GHSA-ppp5-vxwm-4cf7). When the dashboard is placed behind a reverse proxy — including Tailscale Serve, nginx, Caddy, or any similar TLS terminator — the proxy forwards the originalHostheader from the client (e.g.kevins-mini.tail79ec1f.ts.net) to the local server. Because that hostname doesn't match the loopback allowlist, every request is rejected with:{"detail": "Invalid Host header. Dashboard requests must use the hostname the server was bound to."}This makes it impossible to use the dashboard remotely via Tailscale Serve (the recommended way to expose local services on a tailnet with automatic TLS) or any other reverse proxy, without resorting to
--insecure --host 0.0.0.0which exposes the dashboard on all interfaces.Use Case
Tailscale Serve is a common pattern for securely exposing local services to a private tailnet:
This proxies
https://<machine>.tail<id>.ts.net→http://127.0.0.1:9119. Tailscale handles TLS automatically. The dashboard stays loopback-bound (never exposed to the LAN), but the host header the local server sees is the MagicDNS hostname, not127.0.0.1.The same pattern applies to any TLS-terminating reverse proxy (nginx
proxy_pass, Caddyreverse_proxy, Traefik, etc.).Proposed Fix
Add an
--allowed-hostsCLI flag that accepts a comma-separated list of additional hostnames to whitelist in the host-header middleware. The DNS rebinding protection is fully preserved — only explicitly listed names are added to the allowlist; no wildcard or bypass logic is introduced.hermes_cli/web_server.py1.
_is_accepted_host— addextra_allowedparameter:2.
host_header_middleware— readapp.state.extra_allowed_hosts:3.
_ws_host_origin_is_allowed— same for WebSocket upgrades:4.
start_server— newallowed_hostskeyword argument:hermes_cli/main.pyAdd
--allowed-hoststo the dashboard argument parser:Pass it through in the
cmd_dashboardhandler:Usage After Fix
Tailscale Serve (recommended — stays loopback-bound, automatic TLS):
Explicit non-loopback bind (for nginx/Caddy on LAN):
Security Considerations
extra_allowedis an explicit opt-in list with no wildcard matching.127.0.0.1— it is never exposed on any network interface.--insecureis not required.Alternatives Considered
--host 0.0.0.0 --insecure: Works but exposes the dashboard on all interfaces (LAN, WiFi, etc.), not just the Tailscale interface. Unnecessary risk.--host 100.x.x.x): Limits exposure to the Tailscale interface, but the exact-match host check still rejects the MagicDNS hostname. Requires--allowed-hostsanyway.--insecure.