Skip to content

Dashboard: accept extra Host values so Tailscale MagicDNS names work - #62301

Open
Zeus-Deus wants to merge 3 commits into
NousResearch:mainfrom
Zeus-Deus:feat/gateway-allowed-hosts
Open

Dashboard: accept extra Host values so Tailscale MagicDNS names work#62301
Zeus-Deus wants to merge 3 commits into
NousResearch:mainfrom
Zeus-Deus:feat/gateway-allowed-hosts

Conversation

@Zeus-Deus

@Zeus-Deus Zeus-Deus commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What this does

The dashboard rejects any request whose Host header does not exactly match the address the server was bound to. That is good DNS rebinding protection, but it makes some very normal setups impossible. My case: the gateway runs on my home server inside my tailnet, and I want to reach it by its Tailscale MagicDNS name. If I bind to the Tailscale IP, connecting via the hostname gets a 400 Invalid Host header. Same story for tailscale serve, which proxies to a loopback bind but forwards the external hostname.

This PR adds an opt in allowlist of extra Host values the server will accept. You can set it two ways, and they merge together:

a repeatable flag: hermes serve --host 100.x.y.z --allowed-host myserver.tailnet.ts.net

config: dashboard.allowed_hosts in config.yaml

Entries are normalized the same way the Host header is parsed (case, port, IPv6 brackets, full URLs, trailing dot), and the check goes through the same helper used by the HTTP middleware and both WebSocket host and origin guards, so all three surfaces behave the same.

The security part

Setting a non empty allowlist forces the auth gate on, regardless of the bind address. Allowlisting an external hostname means you expect requests from beyond the local machine, and without this rule a loopback bind behind tailscale serve would hand the unauthenticated dashboard, including its injected session token, to everyone who can reach the proxy. The existing fail closed check applies too: if the allowlist is set and no auth provider is configured, the server refuses to start, and the error message now names the allowlist as the reason. Starting it interactively in that state offers the auth setup prompt first instead of failing right away, through the same effective_auth_required condition start_server uses. Nothing changes when the allowlist is empty, which is the default.

Testing

New unit tests cover the validator with allowed hosts on loopback and non loopback binds, the entry normalization, the config plus flag merging, the auth forcing rule, the setup preflight on a loopback bind with an allowlist, the HTTP middleware accept and reject paths, and WebSocket accept and 4403 reject. The full host header suite passes (26 tests), along with the auth gate and WebSocket auth suites (142 passed total), and ruff is clean.

I also verified it against a live server: an allowlisted MagicDNS name is accepted with and without a port and with different casing, an unknown host still gets 400, /api/status reports the forced auth gate, starting with an allowlist but no auth provider refuses to boot with a helpful message, and a server without an allowlist behaves exactly as before.

Docs updated in the web dashboard guide (flag table, DNS rebinding note, a new MagicDNS section under Tailscale), SECURITY.md, and the security user guide. I left the zh-Hans translation mirror untouched.

Related to #62293, which adds a connect to server option to the desktop app first launch. Together they make the home server setup work end to end over Tailscale.

The dashboard rejects any Host header that does not match the bound address,
which is good DNS rebinding protection but makes it impossible to reach a
gateway through a name that differs from the bind, like a Tailscale MagicDNS
hostname or a tailscale serve proxy in front of a loopback bind.

This adds an opt in allowlist of extra accepted Host values, configurable
three ways that merge as a union: a repeatable --allowed-host flag on
hermes dashboard and hermes serve, a HERMES_DASHBOARD_ALLOWED_HOSTS env var
(comma separated), and dashboard.allowed_hosts in config.yaml. Entries are
normalized (case, port, IPv6 brackets, URL forms, trailing dot) to match the
header parsing, and the check applies to the HTTP middleware and both
WebSocket host and origin guards through the same helper.

Setting a non empty allowlist forces the auth gate regardless of bind
address, since an allowlisted external name means requests are expected from
beyond the local machine; the existing fail closed provider check then
applies, with the error text extended to name the allowlist as the reason.
@alt-glitch alt-glitch added type/feature New feature or request comp/dashboard Web dashboard / control panel UI (dashboard/, landing) comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P3 Low — cosmetic, nice to have labels Jul 10, 2026
@alt-glitch

alt-glitch commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: this remains a competing reverse-proxy/MagicDNS Host-allowlist implementation alongside feature anchor #34390 and open work #37119 (durable-service consolidation) and #20515 (Tailscale identity auth). The live branch supports only repeatable --allowed-host and dashboard.allowed_hosts, applies the normalized allowlist to HTTP and WebSocket checks, and forces authentication when it is non-empty; it is not a duplicate of one narrower implementation.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for addressing the real reverse-proxy/MagicDNS limitation: current main rejects a non-bound Host in both HTTP (hermes_cli/web_server.py:460-486) and WebSocket (hermes_cli/web_server.py:14284-14315) paths.

Problems

  • The new forced-auth branch needs to be reflected in the interactive setup preflight. hermes_cli/main.py:11829-11834 exits early for every loopback bind, while this PR makes a loopback bind with an allowed host require auth in start_server(). An interactive user can therefore skip setup and then hit the fail-closed error.
  • The new HERMES_DASHBOARD_ALLOWED_HOSTS interface is a non-secret behavioral setting. AGENTS.md requires those settings to use config.yaml; please avoid documenting a new .env configuration surface.

Suggested changes

  • Share the effective auth-required condition between the preflight and start_server(), then add a loopback-plus-allowlist regression test.
  • Keep dashboard.allowed_hosts as the persistent mechanism and remove the new environment-variable path.

This is an automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
…e check with the setup preflight

Review feedback on the allowed hosts allowlist. The
HERMES_DASHBOARD_ALLOWED_HOSTS env var is gone since .env is for
secrets only, so the allowlist is now set through the repeatable
--allowed-host flag or dashboard.allowed_hosts in config.yaml and the
two sources merge as a union. Docs and .env.example updated to match.

The interactive setup preflight used to skip every loopback bind, but
a non empty allowlist forces the auth gate on even there, so an
interactive user could skip setup and then hit the fail closed error
at startup. Both paths now share a single effective_auth_required
helper, and the preflight prompt explains that the allowlist is what
engaged the gate when the bind itself is loopback. Adds a loopback
plus allowlist regression test along with tests for the helper.
@Zeus-Deus

Copy link
Copy Markdown
Contributor Author

Both points addressed in b9cac28.

Env var removed. The allowlist is now set only through the repeatable --allowed-host flag or dashboard.allowed_hosts in config.yaml, merged as a union. The HERMES_DASHBOARD_ALLOWED_HOSTS path is gone from the code, .env.example is back to what main has, and every doc mention (SECURITY.md, the security guide, the web dashboard guide, the flag help, the config comment) now points at the flag and config only. I also checked against a live server that a host named only in the env var gets a 400 again while the server starts ungated on loopback, so nothing silently reads it anymore.

Preflight and start_server now share one condition. Added effective_auth_required(host, allowed_hosts) in web_server.py next to should_require_auth, which keeps its pure bind address semantics for other callers. start_server uses it for app.state.auth_required, and the interactive preflight resolves the allowlist from the args and early returns only when the shared condition says the gate stays off. So a loopback bind with an allowed host now gets the interactive auth setup instead of falling through to the fail closed SystemExit. The prompt also explains why the gate engaged in that case, since the old wording about binding to a non loopback address would be wrong there:

⚠ The dashboard has an allowed-hosts allowlist set, which requires an auth provider even on a loopback bind.
  Allowlisting an external hostname means requests can arrive from beyond this machine.

  How do you want to authenticate the dashboard?
    [1] Username & password (quickest; for a trusted LAN / VPN)
    [2] OAuth via Nous Portal (run `hermes dashboard register`)
    [3] Cancel

Tests: the new regression test runs the preflight with a loopback bind plus an allowlist and asserts it prompts instead of returning early, with a control case for the empty allowlist and unit tests for the helper. The union test now covers CLI plus config. 26 pass in the host header suite and 59 across that plus the auth gate suites, ruff clean. I also reran the live checks from the PR description: an allowlisted name is accepted with and without a port and with different casing, a config sourced entry works alongside the flag, an unknown host still gets 400, and a non TTY start with an allowlist but no provider still refuses to boot with the message naming the allowlist as the reason.

@Zeus-Deus

Copy link
Copy Markdown
Contributor Author

@teknium1 Bumping this one too since it's been quiet for a bit. Both review points are addressed in b9cac28 and the branch is still conflict free and mergeable against current main.

Quick recap of where it stands: the HERMES_DASHBOARD_ALLOWED_HOSTS env var is gone entirely, so the allowlist is set only through the repeatable --allowed-host flag or dashboard.allowed_hosts in config.yaml, with docs and .env.example matching. And the preflight and start_server now share the effective_auth_required condition, so a loopback bind with an allowlist gets the interactive auth setup prompt instead of falling through to the fail closed error, covered by the loopback plus allowlist regression test you asked for. Host header and auth gate suites are green and the whole flow was verified against a live server, details in my previous comment.

Ready for another look whenever you get a chance.

@Kinkoolino-Hermes

Copy link
Copy Markdown
Contributor

One small reviewability cleanup remains: the PR description still says the allowlist has three sources and lists HERMES_DASHBOARD_ALLOWED_HOSTS; its Testing section also still says “config plus env plus flag merging” and lists .env.example among the updated allowlist docs. At current head b9cac28, the supported sources are the repeatable --allowed-host flag plus dashboard.allowed_hosts only.

Could you update the description and test summary to match the current head so the intended config/CLI behavior is unambiguous for final review?

@Zeus-Deus

Copy link
Copy Markdown
Contributor Author

Good catch, the description was still describing the pre review version. Updated it to match the current head: two sources only, the repeatable --allowed-host flag and dashboard.allowed_hosts in config.yaml, and the Testing section now reflects the current suites (26 in the host header suite, 142 total with the auth gate and WebSocket auth suites) plus the setup preflight coverage that was added during review. No .env.example changes anymore either since the env var path is gone.

@alt-glitch alt-glitch added needs-decision Awaiting maintainer decision before any implementation and removed comp/cli CLI entry point, hermes_cli/, setup wizard labels Jul 19, 2026
# Conflicts:
#	hermes_cli/main.py
#	hermes_cli/web_server.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/config Config system, migrations, profiles comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants