Skip to content

fix(dashboard): accept HERMES_DASHBOARD_PUBLIC_URL hostname in Host-header validation - #68251

Open
webtecnica wants to merge 2 commits into
NousResearch:mainfrom
webtecnica:fix/dashboard-host-header-public-url
Open

fix(dashboard): accept HERMES_DASHBOARD_PUBLIC_URL hostname in Host-header validation#68251
webtecnica wants to merge 2 commits into
NousResearch:mainfrom
webtecnica:fix/dashboard-host-header-public-url

Conversation

@webtecnica

Copy link
Copy Markdown
Contributor

Summary

When the dashboard is bound to a loopback address (127.0.0.1) behind a reverse proxy (Nginx, Caddy, Traefik, etc.), the Host-header middleware rejects all proxied requests because the public hostname doesn't match any loopback alias (localhost, 127.0.0.1, ::1).

Root Cause

_is_accepted_host() only accepts loopback hostnames when bound to loopback. There is no mechanism to declare additional accepted hostnames.

Fix

Three changes in hermes_cli/web_server.py:

  1. _resolve_allowed_hosts() — new helper that extracts the hostname from dashboard.public_url / HERMES_DASHBOARD_PUBLIC_URL env var
  2. _is_accepted_host() — new optional extra_hosts param; when bound to loopback and the Host header isn't a loopback alias, also checks against the extra set
  3. start_server() — stores app.state.allowed_hosts at startup so the middleware can use it

The public URL is an operator-declared config value, never extracted from request headers — so the DNS-rebinding protection (GHSA-ppp5-vxwm-4cf7) is fully preserved.

Security

  • Loopback-only behaviour is unchanged when HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url is unset (the default)
  • Only the operator-declared value is trusted — no header-based host override
  • The existing enforcement for non-loopback binds (auth gate requiring OAuth or password) is untouched

Testing

  • http://127.0.0.1:9119/ → 200 (direct, unchanged)
  • https://public.example.com/ (via Nginx → 127.0.0.1:9119) → 200 (was 400 before the fix)
  • https://evil-attacker.com/ → 400 (rejected, DNS-rebinding still blocked)

…eader validation

When the dashboard is bound to a loopback address (127.0.0.1) behind a
reverse proxy (Nginx, Caddy, etc.), the Host-header middleware rejects
requests because the public hostname doesn't match any loopback alias.

- Add _resolve_allowed_hosts() that extracts the hostname from
  dashboard.public_url / HERMES_DASHBOARD_PUBLIC_URL
- Extend _is_accepted_host() with an extra_hosts parameter
- Store allowed_hosts on app.state at startup and pass it to the
  middleware check

Only the operator-declared public URL is accepted — never extracted from
request headers — so the DNS-rebinding protection (GHSA-ppp5-vxwm-4cf7)
is preserved. Loopback-only behaviour is unchanged when public_url is
unset (the default).
@alt-glitch alt-glitch added type/bug Something isn't working comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have 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 labels Jul 20, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #42344 is an open broader implementation of the same loopback reverse-proxy Host-header behavior, with malformed-host and middleware coverage. Consider consolidating on one implementation.

@webtecnica

Copy link
Copy Markdown
Contributor Author

CI Status Update

The CI pipeline has been fixed and is now all green:

  • Check contributors / check-attribution — Fixed by adding the missing contributor mapping for webtecnica.dev@users.noreply.github.com
  • ✅ All Python tests, lints, Docker builds, OSV scans, and supply-chain checks pass
  • mergeable: MERGEABLE, mergeStateStatus: CLEAN — no conflicts

This PR needs a review from a maintainer before it can be merged. Ready for review! 🙏

@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 the focused reverse-proxy report. Current main does reject a configured public hostname on a loopback bind (hermes_cli/web_server.py:493-496), but this patch needs security and WebSocket completion before it can safely solve the deployment.

Problems

  • The added allowed_hosts path only reaches HTTP middleware. WebSocket upgrades separately validate Host and Origin at hermes_cli/web_server.py:17112 and :17129; the PR does not pass its allowlist there, so proxied dashboard PTY/chat sockets remain rejected.
  • start_server() derives auth_required solely from the bind host at hermes_cli/web_server.py:19822. Permitting an external configured hostname through a loopback bind therefore exposes the loopback token mode rather than engaging the existing auth gate.
  • The patch has no regression tests or docs. website/docs/user-guide/features/web-dashboard.md:909 currently describes public_url as OAuth-callback-only.

Suggested changes

  • Resolve one validated trusted-host set at startup, use it for HTTP and WebSocket Host/Origin checks, and enable/fail-close the auth gate for external configured hosts.
  • Add exact-match HTTP/WS and cross-origin rejection tests, plus update the public-url documentation.

Automated hermes-sweeper review.

Comment thread hermes_cli/web_server.py
bound_lc = bound_host.lower()
if bound_lc in _LOOPBACK_HOST_VALUES:
return host_only in _LOOPBACK_HOST_VALUES
if host_only in _LOOPBACK_HOST_VALUES:

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.

This allowlist reaches only host_header_middleware. WebSocket upgrades independently call _is_accepted_host() for Host and Origin (hermes_cli/web_server.py:17112,17129 on current main), so a browser using this proxy hostname still fails its WebSocket handshake. Thread the same app-state allowlist through that guard and add WebSocket coverage.

Comment thread hermes_cli/web_server.py
# Record the bound host so host_header_middleware can validate incoming
# Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7).
app.state.bound_host = host
app.state.allowed_hosts = _resolve_allowed_hosts()

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.

This makes an externally reachable hostname trusted after auth_required was already derived from the loopback bind. Current start_server() sets the gate from should_require_auth(host) (hermes_cli/web_server.py:19822), so this configuration preserves the loopback token mode while exposing it through the proxy. A non-loopback configured public host must engage the auth gate and fail closed without a provider.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 30, 2026
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Twenty-seven PRs address or reference the reverse-proxy dashboard Host/Origin rejection cluster: they range from CLI/env/config Host allowlists to public_url-based WebSocket-Origin handling and auth-gate scoping. The diffs show that HTTP-only allowlists do not solve WebSocket failures, while externally accepted loopback proxy hosts also require an explicit authentication boundary.

Related pull requests

Duplicates

#20884, #25173, #27113, #28954, #29195, #31304, #32109, #32362, #38766, #47560, #47903, #48954, #68251, #68645, #70064, #70470, #75019, #75053, and #75907 overlap on extra Host/Origin allowlisting; #42344, #53340, #56898, #62639, and #65965 overlap on dashboard.public_url-based trust, while #50380 and #50886 are explicit WebSocket-Origin variants.

Suggested consolidation

Author action: rebase #68251 onto main, or split out the part that can merge, but first address the contributor keep_open review by threading one validated config-backed trust set through HTTP and WebSocket Host/Origin checks, engaging or failing closed on the auth gate for externally reachable loopback deployments, and adding route-level tests and docs. Keep #75053 open as the strongest config-only multi-host salvage path and #65965 open as the narrow public_url-Origin path; do not reopen the closed env-only variants, whose reusable tests can be carried into those implementations.

Complex graph

flowchart TD
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I50365(["issue #50365 (closed)"])
    I70059(["issue #70059 (open)"])
    subgraph Dup42344 ["PRs duplicating each other"]
        P42344["PR #42344 (open)"]
        P53340["PR #53340 (closed)"]
        P56898["PR #56898 (closed)"]
        P62639["PR #62639 (open)"]
        P65965["PR #65965 (open)"]
        P68251["PR #68251 (open)"]
    end
    P68251 -.->|partial| I50365
    P68251 -.->|partial| I70059
    class I50365 closed
    class I70059 open
    class P42344 open
    class P53340 closed
    class P56898 closed
    class P62639 open
    class P65965 open
    class P68251 open
    class P53340 best
    class P65965 best
    class P68251 target
    click I50365 "https://github.com/NousResearch/hermes-agent/issues/50365"
    click I70059 "https://github.com/NousResearch/hermes-agent/issues/70059"
    click P42344 "https://github.com/NousResearch/hermes-agent/pull/42344"
    click P53340 "https://github.com/NousResearch/hermes-agent/pull/53340"
    click P56898 "https://github.com/NousResearch/hermes-agent/pull/56898"
    click P62639 "https://github.com/NousResearch/hermes-agent/pull/62639"
    click P65965 "https://github.com/NousResearch/hermes-agent/pull/65965"
    click P68251 "https://github.com/NousResearch/hermes-agent/pull/68251"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 27 pull requests and 3 issues in this complex. Each diff was read against this issue; Assessment working set: 304 kB of PR diffs, 79 kB of issue/PR text, 52 kB of discussion (77 comments), 88 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

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

Labels

area/config Config system, migrations, profiles comp/dashboard Web dashboard / control panel UI (dashboard/, landing) 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/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants