fix(dashboard): accept HERMES_DASHBOARD_PUBLIC_URL hostname in Host-header validation - #68251
fix(dashboard): accept HERMES_DASHBOARD_PUBLIC_URL hostname in Host-header validation#68251webtecnica wants to merge 2 commits into
Conversation
…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).
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. |
|
CI Status Update ✅ The CI pipeline has been fixed and is now all green:
This PR needs a review from a maintainer before it can be merged. Ready for review! 🙏 |
teknium1
left a comment
There was a problem hiding this comment.
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_hostspath only reaches HTTP middleware. WebSocket upgrades separately validate Host and Origin athermes_cli/web_server.py:17112and:17129; the PR does not pass its allowlist there, so proxied dashboard PTY/chat sockets remain rejected. start_server()derivesauth_requiredsolely from the bind host athermes_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:909currently describespublic_urlas 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.
| 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: |
There was a problem hiding this comment.
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.
| # 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() |
There was a problem hiding this comment.
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.
SummaryTwenty-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 consolidationAuthor 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 graphflowchart 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"
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. |
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:_resolve_allowed_hosts()— new helper that extracts the hostname fromdashboard.public_url/HERMES_DASHBOARD_PUBLIC_URLenv var_is_accepted_host()— new optionalextra_hostsparam; when bound to loopback and the Host header isn't a loopback alias, also checks against the extra setstart_server()— storesapp.state.allowed_hostsat startup so the middleware can use itThe 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
HERMES_DASHBOARD_PUBLIC_URL/dashboard.public_urlis unset (the default)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)