Skip to content

dashboard: reverse-proxy Host allowlist + auth-gate scoping (extra_hosts) - #75907

Closed
ghost wants to merge 2 commits into
mainfrom
unknown repository
Closed

dashboard: reverse-proxy Host allowlist + auth-gate scoping (extra_hosts)#75907
ghost wants to merge 2 commits into
mainfrom
unknown repository

Conversation

@ghost

@ghost ghost commented Aug 1, 2026

Copy link
Copy Markdown

Problem

A loopback-bound dashboard fronted by a trusted reverse proxy (e.g. tailscale serve127.0.0.1:9119) is unusable: the browser's Host header carries the proxy hostname, which the DNS-rebinding allowlist rejects.

And if you do let the proxy through, "loopback bind == trusted operator" no longer holds — the dashboard is remote-reachable and must be auth-gated.

Change

  • HERMES_DASHBOARD_EXTRA_HOSTS env var + dashboard.extra_hosts config list: operator-designated Host values accepted on any bind. Rebinding protection is preserved for every unlisted name.
  • Declaring any extra host re-engages the auth gate even on loopback binds (should_require_auth), closing the proxied-but-unauthenticated hole.
  • Scoping: the gate-on-extra-hosts rule applies only to non-headless (dashboard) processes. The headless serve path — the desktop app's own backend on an ephemeral loopback port the proxy never fronts — keeps the loopback token path. Gating it breaks the desktop client, whose WS handshake uses the legacy ?token= credential that gated mode unconditionally rejects (_ws_auth_reason). A non-loopback headless bind still engages the gate via the existing fallback.

Verification

On a real install: the tailscale-proxied dashboard passes Host validation and requires auth; the desktop app boots and connects normally; a loopback dashboard with no extra hosts behaves exactly as before.

The headless scoping was found the hard way — without it the desktop app hard-fails at boot with "the WebSocket rejected the session token".

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing a real reverse-proxy deployment gap. Current main rejects a proxy hostname on a loopback bind in hermes_cli/web_server.py:499-502, and applies the same check to WebSocket Host/Origin headers at hermes_cli/web_server.py:14388-14407.

Problems

  • The headless exemption is only threaded into start_server(). The earlier interactive preflight still calls should_require_auth(host) at hermes_cli/main.py:9905-9907. With extra hosts configured, an interactive hermes serve can enter auth setup even though the later server call is headless and leaves the gate off.
  • HERMES_DASHBOARD_EXTRA_HOSTS is a new non-secret behavioral environment variable. AGENTS.md:102-105 requires this kind of setting to use config.yaml instead.
  • The PR changes only hermes_cli/web_server.py; it does not add dashboard.extra_hosts to hermes_cli/config_defaults.py, document it in website/docs/user-guide/configuration.md, or add coverage for HTTP, WebSocket, gated dashboard, and headless-serve behavior.

Suggested changes

  • Propagate the headless value through the auth preflight and test both dashboard and serve paths.
  • Keep this as a documented dashboard.extra_hosts config setting, without the new environment variable.
  • Add regression tests for listed and unlisted Host/Origin values and the auth/token split.

Automated hermes-sweeper review.

Comment thread hermes_cli/web_server.py
@@ -17096,7 +17150,7 @@ def start_server(
# injection / WS-auth paths can branch on it consistently. Phase 3.5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start_server() now passes headless, but hermes_cli/main.py:9905-9907 still calls should_require_auth(host) without it before this point. With dashboard.extra_hosts set, an interactive hermes serve enters auth setup even though this call sets auth_required=False. Thread the same headless value into that preflight and cover both paths.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d4d3a3d — the preflight now threads headless=getattr(args, "headless_backend", False) into should_require_auth, matching start_server. Both paths (serve no-prompt, dashboard prompt) covered by test_preflight_threads_headless_into_should_require_auth.

…sts)

A loopback-bound dashboard behind a trusted reverse proxy (e.g. tailscale serve
-> 127.0.0.1:9119) is unusable today: the browser's Host header carries the proxy
hostname, which the DNS-rebinding allowlist rejects. And once the proxy is let
through, "loopback bind == trusted operator" no longer holds — the dashboard is
remote-reachable and must be auth-gated.

- HERMES_DASHBOARD_EXTRA_HOSTS env var + dashboard.extra_hosts config list:
  operator-designated Host values accepted on any bind. Rebinding protection is
  preserved for every unlisted name.
- Declaring any extra host re-engages the auth gate even on loopback binds
  (should_require_auth), closing the proxied-but-unauthenticated hole.
- Scope that rule to non-headless (dashboard) processes. The headless serve path
  — the desktop app's own backend on an ephemeral loopback port the proxy never
  fronts — keeps the loopback token path; gating it breaks the desktop client,
  whose WS handshake uses the legacy ?token= credential that gated mode
  unconditionally rejects (_ws_auth_reason). A non-loopback headless bind still
  engages the gate via the existing fallback.
@ghost
ghost force-pushed the dashboard-proxy-extra-hosts branch from efbb6c6 to 16c206a Compare August 1, 2026 05:54
@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data 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 Aug 1, 2026
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/auth Authentication, OAuth, credential pools needs-decision Awaiting maintainer decision before any implementation labels Aug 1, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The extra-host path correctly rejects unlisted names and enables gated authentication for the normal dashboard, but the headless exception splits the security invariant: a configured proxy hostname is still accepted by Host and WebSocket-origin validation while headless loopback startup leaves auth_required false. That makes the public status endpoint return local-only deployment details to a remote caller whenever a reverse proxy routes to a headless serve process. The behavior reproduces against the bound revisions, so Host acceptance and auth scoping need to remain atomic: either reject configured proxy hosts in headless mode or engage the gated security posture whenever headless accepts one.

  • [P3] Headless mode accepts proxy hosts without switching public responses to remote-safe scope (hermes_cli/web_server.py:506)
    should_require_auth() ignores configured extra hosts when headless=True, while _is_accepted_host() accepts those hosts on every bind and _ws_host_origin_reason() reuses that helper. A reverse proxy routed to a loopback headless process can therefore pass the new Host/Origin boundary while app.state.auth_required remains false. /api/status is public and includes hermes_home, config_path, env_path, gateway_pid, gateway_health_url, and gateway port topology whenever auth is not required. An unauthenticated remote caller through the configured proxy can obtain deployment reconnaissance that the endpoint intentionally omits in network-exposed mode. Please reject extra hosts for headless processes, or require gated authentication and a compatible WebSocket credential path when accepting them. Add HTTP and WebSocket tests for headless plus extra_hosts, including an unauthenticated /api/status assertion that remote responses contain none of the local-only fields.

Security evidence:

  • trust boundary: Untrusted HTTP Host and WebSocket Host/Origin values supplied by a remote browser or proxy cross the boundary configured by operator-controlled HERMES_DASHBOARD_EXTRA_HOSTS and dashboard.extra_hosts; the protected sinks are HTTP routing, WebSocket admission, auth-mode selection, and public status data.
  • source/sink/invariant: _get_extra_allowed_hosts() supplies names accepted by _is_accepted_host(), while should_require_auth() sets app.state.auth_required and /api/status uses that flag to decide whether to include local deployment detail. Any proxy hostname accepted for remote reachability must use the remote-safe posture: gated authentication and a status response stripped of local-only fields.
  • current-main reproduction: On bound current main 9fc12bf7a4bc232698a14acfb18621523a711ceb, the focused probe returned false for _is_accepted_host('proxy.example:443', '127.0.0.1') and false for should_require_auth('127.0.0.1'), keeping that unconfigured proxy hostname outside the loopback Host boundary.
  • PR-head or patch-replay validation: The one-file PR patch applied cleanly in check mode to the bound current-main copy. On bound head 16c206a38bc8fe8673dfd9a4fef33b466a783a85, with proxy.example configured as an extra host, the probe returned true from _is_accepted_host('proxy.example:443', '127.0.0.1') while should_require_auth('127.0.0.1', headless=True) returned false, reproducing the split invariant.
  • positive/negative cases: Configured mixed-case proxy names with ports were accepted, normal non-headless loopback dashboards with extra hosts required auth, and non-loopback headless binds required auth; unlisted evil.example and proxy.example on current main were rejected, while the adverse configured-proxy plus loopback-headless case remained accepted with auth off.
  • residual bypass search: Both host_header_middleware and _ws_host_origin_reason() share _is_accepted_host(); the headless peer-IP exception still permits a same-machine reverse proxy, and public /api/status exposes the concrete local-only fields when auth is off. Other non-public API and WebSocket routes retain ephemeral-token checks, so no broader unauthenticated control bypass was established.
  • reviewer validation: git diff --check, python -B -m compileall hermes_cli/web_server.py, the focused current-main/head policy probe, and the patch-application check all passed.

Signed: GPT-5.6-sol-xhigh in Codex

…s into preflight

Review follow-up:

- teknium1: _maybe_setup_dashboard_auth_interactively now calls
  should_require_auth(host, headless=...) with the same headless value
  start_server receives, so interactive 'hermes serve' no longer enters
  auth setup for a gate that will be off (and 'hermes dashboard' still
  prompts when extra_hosts engage the gate).
- egilewski: Host acceptance and auth scoping are now atomic.
  _is_accepted_host() takes headless and rejects configured extra hosts
  for headless processes; app.state.headless is set by start_server and
  consulted by the HTTP middleware and _ws_host_origin_reason. A proxy
  routed to a headless loopback serve now gets 400/host_mismatch instead
  of reaching /api/status with auth_required=False. Rejection chosen
  over engaging the gate for headless: gated mode rejects the desktop
  app's legacy ?token= WS credential, and headless serve is never
  proxy-fronted by design.

Tests: 11 new cases in test_dashboard_auth_gate.py (truth-table rows,
_is_accepted_host accept/reject, HTTP 400 asserting no local-only
/api/status fields, WS host_mismatch, preflight threading spy).
scripts/run_tests.sh green on all four files touching these symbols.
@ghost

ghost commented Aug 2, 2026

Copy link
Copy Markdown
Author

Follow-up commit d4d3a3d addresses both reviews.

@teknium1 — preflight threading: _maybe_setup_dashboard_auth_interactively now calls should_require_auth(host, headless=...) with the same headless value start_server receives (args.headless_backend). Interactive hermes serve + extra_hosts no longer enters auth setup for a gate that will be off; interactive hermes dashboard + extra_hosts still prompts. Both paths are covered by test_preflight_threads_headless_into_should_require_auth (spy on the predicate).

@egilewski — split invariant: closed by making Host acceptance and auth scoping atomic. _is_accepted_host() now takes headless and rejects configured extra hosts for headless processes; start_server stashes app.state.headless, consulted by both host_header_middleware (HTTP → 400) and _ws_host_origin_reason (WS → host_mismatch). A reverse proxy routed to a headless loopback serve is now refused at the boundary, so /api/status with auth_required=False is unreachable through it.

I chose rejection over engaging the gate for headless (your either/or): gated mode unconditionally rejects the desktop app's legacy ?token= WS credential (_ws_auth_reason), and the headless serve backend is never proxy-fronted by design — dashboard.extra_hosts is global config, so gating headless would break the desktop backend on any machine that also proxies its interactive dashboard. The proxied deployment path remains the gated interactive dashboard, where the feature is unchanged.

Tests: 11 new cases in tests/hermes_cli/test_dashboard_auth_gate.py — truth-table rows (headless × extra_hosts × bind), _is_accepted_host accept/reject incl. the rebinding floor for unlisted names, an HTTP boundary test asserting the 400 body contains none of the local-only /api/status fields (hermes_home, config_path, env_path, gateway_pid, gateway_health_url), the WS host_mismatch reason, and the preflight spy. scripts/run_tests.sh green on all four files touching these symbols (auth gate, host-header, web_server, pty_keepalive_ws).

@andrexibiza

Copy link
Copy Markdown
Contributor

Verification (swarm dedup campaign, 2026-08-03) — green-light with evidence, tested against current main 6858e0d93.

  • Merge: clean 3-way merge of head d4d3a3ddc into current main, no conflicts (hermes_cli/main.py, hermes_cli/web_server.py, tests/hermes_cli/test_dashboard_auth_gate.py).
  • GREEN: tests/hermes_cli/test_dashboard_auth_gate.py → 23 passed (incl. all 9 new tests); test_web_server.py + test_web_server_host_header.py → 124 passed, 4 skipped. No regressions.
  • RED: the 7 new security-invariant tests (extra_hosts gate engagement, headless rejection at the HTTP + WS boundary, preflight headless threading) all FAIL on unpatched main — they assert the absence, so the split-invariant fix is real, not cosmetic.
  • git diff --check: clean.

Residual asks from the sweeper review (keep_open salvageability=medium) still open at head d4d3a3ddc:

  1. HERMES_DASHBOARD_EXTRA_HOSTS env read in _get_extra_allowed_hosts() — AGENTS.md:102-105 requires non-secret behavioral config to live in config.yaml; drop the env read and keep dashboard.extra_hosts as the single source.
  2. No dashboard.extra_hosts entry in hermes_cli/config_defaults.py (dashboard section) — add "extra_hosts": [] so the key is discoverable/validated.
  3. dashboard.extra_hosts is not documented in website/docs/user-guide/configuration.md (dashboard section).

The security invariant itself (auth gate re-engages when extra hosts are declared; headless serve rejects proxy hosts at the boundary so /api/status never leaks local-only fields remotely) is verified correct. Recommend merge once 1-3 land.

@egilewski

Copy link
Copy Markdown
Contributor

looks mergeable

The security follow-up keeps Host acceptance and auth scoping atomic. Configured proxy hosts engage authentication for the interactive dashboard, while a headless loopback server rejects the same Host at both the HTTP and WebSocket Host/Origin boundaries; the interactive preflight now carries the same headless value into gate selection.

Security evidence:

  • trust boundary: remote proxy/browser Host and Origin values cross into dashboard routing, authentication-mode selection, and the public status response.
  • source/sink/invariant: any configured proxy hostname admitted to an interactive loopback dashboard must engage authentication, while the auth-off headless path must reject that hostname before /api/status or a WebSocket handler runs.
  • current-main reproduction: current main rejects the unconfigured proxy hostname and keeps loopback authentication off.
  • PR-head or patch-replay validation: the clean current-main replay accepts the configured hostname only for the authenticated interactive dashboard and rejects it for headless HTTP and WebSocket traffic.
  • positive/negative cases: the configured interactive hostname is admitted with authentication on; configured headless and unlisted hostnames are rejected.
  • residual bypass search: all production callers of the Host predicate and auth selector now carry the headless state consistently across preflight, HTTP, WebSocket Host, and WebSocket Origin checks.
  • reviewer validation: the invocation probe changed the intended proxy case without weakening the unlisted-host floor, and the focused dashboard-auth suite passes 23/23.

Not checked:

  • Full test suite
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

@alt-glitch alt-glitch added area/config Config system, migrations, profiles and removed needs-decision Awaiting maintainer decision before any implementation labels Aug 10, 2026
@pxxD1998

pxxD1998 commented Aug 12, 2026

Copy link
Copy Markdown

I prepared a local follow-up patch for the three remaining configuration and documentation items identified in the review thread, based on this PR's current head, d4d3a3ddcede9aa27f7316473b7241b7cb8f9edc. This is not a new PR or a commit on the author's branch, and the patch has not been pushed or attached yet.

The patch keeps the existing Host/Origin and headless-auth design intact and only:

  • removes the non-secret HERMES_DASHBOARD_EXTRA_HOSTS behavioral environment variable;
  • keeps dashboard.extra_hosts as the single configuration source;
  • adds the discoverable default dashboard.extra_hosts: [];
  • documents the key in both cli-config.yaml.example and the configuration guide;
  • adds regression coverage that reads a real temporary config.yaml, verifies the legacy environment variable is ignored, and checks the default.

Patch relative to the PR head:

cli-config.yaml.example                      |  7 +++++++
hermes_cli/config_defaults.py                |  5 +++++
hermes_cli/web_server.py                     |  5 +----
tests/hermes_cli/test_dashboard_auth_gate.py | 28 +++++++++++++++++++++++++++-
website/docs/user-guide/configuration.md     |  2 ++
5 files changed, 42 insertions(+), 5 deletions(-)

Verification on the exact PR head plus this patch:

scripts/run_tests.sh \
  tests/hermes_cli/test_dashboard_auth_gate.py \
  tests/hermes_cli/test_web_server.py \
  tests/hermes_cli/test_web_server_host_header.py \
  tests/test_pty_keepalive_ws.py

148 passed, 0 failed

Also passed:

  • Python compilation for the changed Python files;
  • Ruff on the changed Python files;
  • Windows footgun scan (897 files);
  • YAML parsing of cli-config.yaml.example;
  • git diff --check.

I did not run the full repository suite.

The local patch SHA-256 is:

d73283a336fbc7915087f3f90dcc96b6f809e24b40422ede85697e4568db85ef

If this follow-up is useful, I can either paste the raw patch here or publish a commit on my fork for the author to cherry-pick.

@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #70470 and #70059. This implementation keeps configured proxy-host acceptance atomic with auth: interactive dashboards are gated, while headless loopback rejects configured proxy hosts at both HTTP and WebSocket boundaries.

Repository owner closed this by deleting the head repository Aug 17, 2026
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/dashboard Web dashboard / control panel UI (dashboard/, landing) P2 Medium — degraded but workaround exists 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/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants