Allow reverse-proxied hostnames via dashboard --trusted-host allowlist#32109
Closed
exhuma wants to merge 1 commit into
Closed
Allow reverse-proxied hostnames via dashboard --trusted-host allowlist#32109exhuma wants to merge 1 commit into
exhuma wants to merge 1 commit into
Conversation
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).
Collaborator
19 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
--trusted-host HOSTNAMEflag (and matchingHERMES_DASHBOARD_TRUSTED_HOSTSenv var) tohermes dashboardso 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 theHostheader match the interface the server bound to — loopback aliases when bound to loopback, exact match for explicit binds, no protection for0.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: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:
--insecure), which is the situation the loopback bind exists to avoid in the first place — API keys served on the network without robust auth.This PR adds the third, correct option: let the operator name the hostnames their proxy chain forwards, the same shape as Django's
ALLOWED_HOSTSor FastAPI'sTrustedHostMiddleware.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, lowercasedHostvalue that's intrusted_hostsis accepted regardless of the bind. Backwards compatible:Noneor empty set means existing behaviour.host_header_middlewarereadsapp.state.trusted_hostsand passes it through.start_server(..., trusted_hosts: Optional[List[str]] = None)— new keyword arg, merges withHERMES_DASHBOARD_TRUSTED_HOSTSenv var (comma-separated), normalises entries (strip whitespace, strip port, lowercase, drop blanks), stashes the frozen set onapp.state.trusted_hosts. Logs the active allowlist at INFO when non-empty.hermes_cli/main.py--trusted-host HOSTNAMEflag on the dashboard subparser.action='append', repeatable, plumbed intostart_server(trusted_hosts=...).tests/hermes_cli/test_web_server_host_header.pytest_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 likehermes.example.ts.net.evil) still rejected.test_trusted_hosts_none_or_empty_behaves_like_before— regression:Noneand emptyfrozenset()preserve original behaviour exactly.test_trusted_hosts_proxied_request_accepted— middleware integration: end-to-end viaTestClientconfirms 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.pypass against a fresh upstream checkout. The broader dashboard test surface (tests/hermes_cli/matchingdashboard 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
Test plan
tests/hermes_cli/test_web_server_host_header.py— 14/14 passtests/hermes_cli/matchingdashboard or web_server or host_header— 205/205 passtailscale serveHTTPS → 127.0.0.1:9119 before this PR.