Skip to content

Allow reverse-proxied hostnames via dashboard --trusted-host allowlist#32109

Closed
exhuma wants to merge 1 commit into
NousResearch:mainfrom
exhuma:dashboard-trusted-host-allowlist
Closed

Allow reverse-proxied hostnames via dashboard --trusted-host allowlist#32109
exhuma wants to merge 1 commit into
NousResearch:mainfrom
exhuma:dashboard-trusted-host-allowlist

Conversation

@exhuma

@exhuma exhuma commented May 25, 2026

Copy link
Copy Markdown

Summary

Adds a --trusted-host HOSTNAME flag (and matching HERMES_DASHBOARD_TRUSTED_HOSTS env var) to hermes dashboard so that operators behind a reverse proxy can name the hostnames they vouch for, without weakening the existing DNS-rebinding defence (GHSA-ppp5-vxwm-4cf7) for everyone else.

Motivation

The current Host-header validator in hermes_cli/web_server.py (_is_accepted_host / host_header_middleware) protects against DNS rebinding by requiring that the Host header match the interface the server bound to — loopback aliases when bound to loopback, exact match for explicit binds, no protection for 0.0.0.0 (since an operator who opts into that has effectively waived the defence).

That model breaks for one specific, very common deployment shape: reverse-proxied HTTPS in front of a 127.0.0.1 bind. The motivating example is tailscale serve:

tailscale serve  https://hermes.tailnet.ts.net    →    127.0.0.1:9119

The proxy terminates TLS, then forwards the request to the dashboard with Host: hermes.tailnet.ts.net (the public name the client used). The dashboard's loopback bind sees a non-loopback hostname and 400s the request as a rebinding attempt — which is the right call when there's no proxy, but the wrong call here. The operator-configured proxy chain is exactly what's vouching for the hostname.

Operators hitting this today have two bad options:

  1. Bind to a non-loopback interface (--insecure), which is the situation the loopback bind exists to avoid in the first place — API keys served on the network without robust auth.
  2. Disable the rebinding defence entirely (no current upstream flag does this; it would be a regression to add one).

This PR adds the third, correct option: let the operator name the hostnames their proxy chain forwards, the same shape as Django's ALLOWED_HOSTS or FastAPI's TrustedHostMiddleware.

What changes

hermes_cli/web_server.py

  • _is_accepted_host(host_header, bound_host, trusted_hosts=None) — new third arg. When provided and non-empty, a port-stripped, lowercased Host value that's in trusted_hosts is accepted regardless of the bind. Backwards compatible: None or empty set means existing behaviour.
  • host_header_middleware reads app.state.trusted_hosts and passes it through.
  • start_server(..., trusted_hosts: Optional[List[str]] = None) — new keyword arg, merges with HERMES_DASHBOARD_TRUSTED_HOSTS env var (comma-separated), normalises entries (strip whitespace, strip port, lowercase, drop blanks), stashes the frozen set on app.state.trusted_hosts. Logs the active allowlist at INFO when non-empty.

hermes_cli/main.py

  • New --trusted-host HOSTNAME flag on the dashboard subparser. action='append', repeatable, plumbed into start_server(trusted_hosts=...).

tests/hermes_cli/test_web_server_host_header.py

  • test_trusted_hosts_allowlist_accepts_proxied_hostname — unit-level: proxied hostnames accepted (with and without port, case-insensitive), loopback still works, non-allowlisted hostnames (including lookalikes like hermes.example.ts.net.evil) still rejected.
  • test_trusted_hosts_none_or_empty_behaves_like_before — regression: None and empty frozenset() preserve original behaviour exactly.
  • test_trusted_hosts_proxied_request_accepted — middleware integration: end-to-end via TestClient confirms the allowlisted Host passes and a non-allowlisted Host still 400s with the same error message.

All 14 tests in tests/hermes_cli/test_web_server_host_header.py pass against a fresh upstream checkout. The broader dashboard test surface (tests/hermes_cli/ matching dashboard or web_server or host_header) — 205 tests — also passes with no regressions.

Security posture

This is an opt-in widening of the accepted-Host set. With no flag and no env var, behaviour is unchanged — the rebinding defence still rejects every non-loopback Host header on a loopback bind. The allowlist only accepts hostnames the operator has explicitly named, so it can't be abused by a remote attacker without first compromising the operator's config. The threat model is the same as Django ALLOWED_HOSTS: the operator vouches for the proxy chain, and the application stops second-guessing that.

Usage

# Single trusted host (the tailscale serve case)
hermes dashboard --trusted-host hermes.tailnet.ts.net

# Multiple
hermes dashboard --trusted-host hermes.example.com --trusted-host hermes-alt.example.com

# Env var (systemd / docker / ansible ergonomic)
HERMES_DASHBOARD_TRUSTED_HOSTS=hermes.tailnet.ts.net hermes dashboard

Test plan

  • tests/hermes_cli/test_web_server_host_header.py — 14/14 pass
  • tests/hermes_cli/ matching dashboard or web_server or host_header — 205/205 pass
  • Field-tested in production for ~3 days behind tailscale serve HTTPS → 127.0.0.1:9119 before this PR.

The dashboard's DNS-rebinding defence (GHSA-ppp5-vxwm-4cf7) validates the
Host header against the bound interface. With the standard tailscale serve
HTTPS -> 127.0.0.1:9119 topology, the proxy forwards the public hostname
(e.g. hermes.tailnet.ts.net), which the dashboard rejects as a rebinding
attempt because that name is not a loopback alias. Operators are forced to
either bind to a non-loopback interface (exposing the API-keys-on-the-wire
risk that the loopback bind exists to avoid) or disable the rebinding
defence entirely — both bad outcomes for what is a legitimate, common
deployment shape.

Add an operator-vouched allowlist:

- new --trusted-host HOSTNAME flag on hermes dashboard, repeatable
- HERMES_DASHBOARD_TRUSTED_HOSTS env var (comma-separated) for systemd
  unit / docker / ansible-template ergonomics
- entries are case-insensitively matched after port stripping; allowlist
  is additive to the existing bind-derived rules (loopback aliases and
  exact-bound-host matches keep working unchanged)
- empty allowlist preserves existing behaviour exactly — the rebinding
  defence is retained for any Host header not on the list

The opt-in is the same shape as Django's ALLOWED_HOSTS or FastAPI's
TrustedHostMiddleware: the operator names the proxy endpoints they vouch
for, and the app stops second-guessing those names. Without an allowlist
entry, attacker-controlled hostnames are still rejected.

Tests cover both the unit-level helper (_is_accepted_host with a frozenset
allowlist, case-insensitive, port-stripped, no-allowlist behaviour
preserved) and the middleware integration (proxied Host accepted,
non-allowlisted Host still 400s).
@exhuma exhuma closed this May 25, 2026
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard comp/tui Terminal UI (ui-tui/ + tui_gateway/) labels May 25, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This is the 8th+ competing PR for dashboard reverse-proxy host allowlisting. Canonical: #20136 (config-based). Also competes with #27113 (env-var), #28954 (CLI flag + config), #25173, #22437, #31304. PR is now closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/tui Terminal UI (ui-tui/ + tui_gateway/) duplicate This issue or pull request already exists P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants