Skip to content

fix(dashboard): accept dashboard.public_url as a valid WebSocket Origin when loopback-bound - #65965

Open
bbasketballer75 wants to merge 5 commits into
NousResearch:mainfrom
bbasketballer75:local/ws-origin-fix
Open

fix(dashboard): accept dashboard.public_url as a valid WebSocket Origin when loopback-bound#65965
bbasketballer75 wants to merge 5 commits into
NousResearch:mainfrom
bbasketballer75:local/ws-origin-fix

Conversation

@bbasketballer75

Copy link
Copy Markdown

fix(dashboard): accept dashboard.public_url as a valid WebSocket Origin/Host when loopback-bound

Summary

The dashboard's WebSocket Origin/Host guard (_ws_host_origin_reason in hermes_cli/web_server.py) currently rejects any WebSocket upgrade whose Host header or Origin header doesn't match the bound dashboard host. This breaks the loopback-bind + reverse-proxy topology that production users rely on — most notably the cloudflared + Cloudflare Access setup, where:

  1. The dashboard binds to 127.0.0.1:9720 (loopback-only, no public attack surface).
  2. cloudflared terminates TLS at the edge and proxies HTTP/HTTPS into the local service, with originRequest.httpHostHeader: localhost rewriting the Host header to localhost.
  3. The browser opens the SPA at https://<public.host>/chat and then upgrades to wss://<public.host>/api/ws?....
  4. cloudflared does not rewrite the Origin header. Browsers set Origin: https://<public.host> based on the document origin, and that header reaches the dashboard unchanged.
  5. The dashboard compares Origin: https://<public.host> against the bound host (localhost) and rejects the upgrade with 403 Forbidden. The browser sees the failed upgrade as WebSocket close code 1006 (abnormal closure), and the chat surface appears dead even though the user is fully authenticated.

The dashboard already has a dashboard.public_url config option (used for OAuth redirect_uri and X-Forwarded-Prefix reconstruction). This PR extends its semantics so that, when the dashboard is loopback-bound AND dashboard.public_url is configured, the Host/Origin guard accepts the public URL's netloc in addition to the bound host. Operators opt into this explicitly — empty public_url keeps the old strict-loopback behavior.

Reproduction (before this PR)

  1. Bind dashboard to loopback: hermes dashboard --host 127.0.0.1 --port 9720
  2. Front with cloudflared: service: http://localhost:9720 + httpHostHeader: localhost
  3. Configure dashboard.public_url: https://your.host
  4. Open https://your.host/chat, log in via Cloudflare Access
  5. Symptom: chat, events, and PTY WebSockets all close with code 1006 in the browser console. The static page renders, /api/auth/me works when the SPA injects the session token, but every WS upgrade gets 403 from the Origin guard.

Fix

When bound_host in _LOOPBACK_HOSTS and dashboard.public_url resolves to a valid URL, the Host/Origin guard also accepts requests whose Host or Origin netloc matches public_url's host (after stripping any port). This is gated on both conditions — the existing strict-loopback behavior is unchanged when public_url is empty or when the dashboard is bound to a non-loopback host.

The patched helper:

def _host_accepted(value: str) -> bool:
    if _is_accepted_host(value, bound_host):
        return True
    if public_host and value and value.split(":", 1)[0].lower() == public_host:
        return True
    return False

…replaces the two existing _is_accepted_host(...) call sites in _ws_host_origin_reason. public_host is derived from resolve_public_url() (existing helper in hermes_cli.dashboard_auth.prefix); the lookup is best-effort and falls through to the strict-loopback check if config loading fails for any reason.

Why this is safe

  • Explicit opt-in. Behavior changes only when dashboard.public_url is non-empty. Existing loopback-bind users without public_url see no difference.
  • Bounded blast radius. Only loopback-bound dashboards accept the override. Non-loopback binds still use the existing Host/Origin guard (which already accepts localhost/loopback aliases for --insecure binds via _is_accepted_host).
  • No new attack surface for non-loopback binds. The if bound_host in _LOOPBACK_HOSTS guard means a --host 0.0.0.0 or Tailscale-IP bind with a public_url set still runs the original guard. The --insecure flag's explicit-opt-in semantics are preserved.
  • Evil-origin still rejected. Test matrix shows Origin: https://evil.example.com returns 403 even when public_url is set; only the operator-declared public host passes.

Test matrix (verified locally on Windows, dashboard at 127.0.0.1:9720, public_url=https://hermes.theporadas.com)

Host header Origin Before After
127.0.0.1:9720 (none) 101 101
localhost https://hermes.theporadas.com 403 101
127.0.0.1:9720 https://evil.example.com 403 403
evil.example.com https://hermes.theporadas.com 403 403

Diff

--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -14614,8 +14614,33 @@ def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
     if not bound_host:
         return None

+    # When the dashboard is loopback-bound but fronted by a reverse proxy
+    # (e.g. cloudflared + Cloudflare Access) that rewrites the Host header
+    # to ``localhost``, the browser still sends Origin: https://public.host
+    # because cloudflared does not rewrite Origin. Operators opt into this
+    # topology by setting ``dashboard.public_url`` — when present, accept
+    # requests whose Host/Origin matches either the bound host (local dev,
+    # SSH/Tailscale tunnels) OR the public URL's host.
+    public_host: str = ""
+    if bound_host in _LOOPBACK_HOSTS:
+        try:
+            from hermes_cli.dashboard_auth.prefix import resolve_public_url
+            import urllib.parse as _up
+            _purl = resolve_public_url()
+            if _purl:
+                public_host = (_up.urlparse(_purl).netloc or "").lower()
+        except Exception:
+            public_host = ""
+
+    def _host_accepted(value: str) -> bool:
+        if _is_accepted_host(value, bound_host):
+            return True
+        if public_host and value and value.split(":", 1)[0].lower() == public_host:
+            return True
+        return False
+
     host_header = ws.headers.get("host", "")
-    if not _is_accepted_host(host_header, bound_host):
+    if not _host_accepted(host_header):
         return f"host_mismatch host={host_header or '?'} bound={bound_host}"
@@ -14632,7 +14657,7 @@ def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
     if not parsed.netloc:
         return f"origin_mismatch origin={origin} bound={bound_host}"

-    if not _is_accepted_host(parsed.netloc, bound_host):
+    if not _host_accepted(parsed.netloc):
         return f"origin_mismatch origin={origin} bound={bound_host}"
     return None

Operator-facing changes

None. This is purely additive — operators who don't set dashboard.public_url see no change. The existing dashboard.public_url option (already used by OAuth redirect_uri handling and X-Forwarded-Prefix resolution) gains one more consumer.

Recommended config for cloudflared-fronted installs:

dashboard:
  public_url: 'https://your.host'

Plus the existing cloudflared config.yml shape:

ingress:
  - hostname: your.host
    service: http://localhost:9720
    originRequest:
      httpHostHeader: localhost

Local re-apply after hermes update

Until this lands, the patch file at ~/.hermes/patches/ws-public-url.patch (or equivalent) can be re-applied with:

cd <hermes-agent repo>
git apply ~/.hermes/patches/ws-public-url.patch
# restart the dashboard service / process to pick up the change

Related context

  • The dashboard.public_url mechanism was introduced by a890389b6 feat(dashboard-auth): HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url override.
  • The fail-closed _ws_client_reason (line ~14536) for empty peer IPs is unrelated and unaffected.
  • The --insecure flag's bypass path (bound_host not in _LOOPBACK_HOSTS) is unchanged.

Copilot AI review requested due to automatic review settings July 16, 2026 22:12

Copilot AI 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.

Pull request overview

This PR updates the dashboard WebSocket Host/Origin guard to support a common “loopback bind + reverse proxy” deployment by allowing the operator-configured dashboard.public_url host as an additional accepted Host/Origin when the server is bound to loopback.

Changes:

  • Adds best-effort resolution of dashboard.public_url (via resolve_public_url()) inside _ws_host_origin_reason() when loopback-bound.
  • Extends Host/Origin acceptance logic to allow either the bound host or the resolved public host (loopback-only and opt-in via config).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hermes_cli/web_server.py
Comment thread hermes_cli/web_server.py
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/config Config system, migrations, profiles sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jul 16, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks good. No obvious issues found.


Reviewed by Hermes Agent

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused reverse-proxy fix. The premise remains present on current main: hermes_cli/web_server.py:15175 requires the HTTP(S) Origin netloc to satisfy the loopback-only bound-host check, while hermes_cli/dashboard_auth/prefix.py:206 already resolves the operator-declared public URL.

The final diff is appropriately narrow: it is loopback-gated, falls back to the existing guard when no valid public URL resolves, and reuses _is_accepted_host for port and IPv6 handling. The reviewed port/IPv6 concern is addressed in commit 2fb151b7e; the added tests also retain rejection of an unrelated Origin. The shared guard covers /api/ws, /api/pub, and /api/events through _ws_request_is_allowed (hermes_cli/web_server.py:15194), plus the direct /api/console and /api/pty checks (hermes_cli/web_server.py:15856, 16212).

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users label Jul 18, 2026
@Kinkoolino-Hermes

Copy link
Copy Markdown
Contributor

Additional local validation against current main (07e97d2f) found that this change widens the WebSocket Host boundary as well as the intended Origin boundary.

_host_accepted() accepts either the bound host or the host derived from dashboard.public_url, and _ws_host_origin_reason() uses it for both Host and Origin. With a loopback-bound dashboard and a configured public URL, isolated endpoint tests confirmed that both of these combinations are accepted:

  • public Host + loopback Origin;
  • public Host + public Origin.

The normal HTTP host middleware remains stricter and rejects that public Host when the dashboard is loopback-bound. The public host is operator-configured, so this is not a claim of an arbitrary-host bypass; it is a concrete expansion and HTTP/WS policy mismatch beyond the narrow reverse-proxy Origin fix.

The focused dashboard/host/WS suites passed (88 passed), and two additional cross-combination tests confirmed the behavior above.

A narrower contract would keep Host checked only against bound_host, while allowing only Origin to additionally match the single normalized public_url host. Regression tests should reject Host=public, Origin=bound and Host=public, Origin=public, while retaining the existing public-Origin/loopback-Host, port, IPv6, and unrelated-Origin cases.

@bbasketballer75

Copy link
Copy Markdown
Author

Thanks @Kinkoolino-Hermes for the local validation pass. The Host boundary widening is a fair catch, and it's outside the original scope of this PR.

The intent of the change was strictly the Origin header (the browser's Cross-Origin WebSocket gate). Widening Host at the same time is a separate concern that should land as its own PR with a justification for each additional accepted value, OR this PR should be narrowed to ONLY accept dashboard.public_url as a valid Origin (not Host).

Going with the narrower approach: will push a follow-up commit that removes the Host-widening portion of this PR. The loopback-only Host gate stays untouched, and only the Origin check is relaxed to accept dashboard.public_url when loopback-bound. If reviewers want the Host widening as a separate change, that can land as PR #2 with its own discussion.

Will post the commit once it's ready.

@bbasketballer75

Copy link
Copy Markdown
Author

Follow-up per @Kinkoolino-Hermes' local validation pass — pushed a narrowing commit (9fd33ead2) that reverts the Host header widening.

Before this commit: _host_accepted() wrapped both the Host header check AND the Origin check, so the public_url relaxation applied to both.

After this commit:

  • Host header stays gated to _is_accepted_host(host_header, bound_host) — loopback-only, just like upstream.
  • Origin header still uses _host_accepted() which accepts either bound_host or public_host — the original intent of this PR.

The comment in the code explains the rationale (browsers send Origin across reverse proxies like cloudflared, but Host is rewritten upstream to localhost in those topologies, so relaxing it would broaden the surface unnecessarily).

_host_accepted() is preserved in the function body so a follow-up PR that specifically wants to widen Host can reuse it without re-implementing the port/IPv6 handling the second commit added.

PR diff is now +129/-1 on 2 files (was +125/-2). The 4-line increase is the explanatory comment. The 1-line net deletion is reverting one if not _host_accepted(host_header): to if not _is_accepted_host(host_header, bound_host):.

@bbasketballer75 bbasketballer75 changed the title fix(dashboard): accept dashboard.public_url as a valid WebSocket Origin/Host when loopback-bound fix(dashboard): accept dashboard.public_url as a valid WebSocket Origin when loopback-bound Jul 29, 2026
Fixes dashboard WebSocket handshake rejecting the Cloudflare-tunneled
Origin header (1006 close) when dashboard.public_url is set. Adds
_host_accepted() honoring resolve_public_url() alongside the bound
loopback host.
Copilot review feedback on NousResearch#65965:
- public_host was derived via urlparse(...).netloc, which keeps any port,
  while the comparison stripped the port from the incoming value first —
  a public_url with an explicit port could never match.
- The same split(':', 1)[0] also broke on IPv6 literals.

Fix: derive public_host via .hostname (strips port, unwraps IPv6 brackets)
and reuse _is_accepted_host for the comparison instead of re-implementing
port/bracket handling ad hoc.

Adds 3 regression tests, verified to fail against the pre-fix code and
pass against the fix.
- hermes_cli/web_server.py: urllib.parse is already imported at module
  level; drop the redundant local 'import urllib.parse as _up' alias
  and use the module-level import directly, matching the rest of the
  function.
- tests/hermes_cli/test_web_server_host_header.py: drop raising=False
  on the resolve_public_url monkeypatch. It's a real, existing function
  (unlike the dynamic FastAPI app.state attributes patched elsewhere in
  this file, where raising=False is correct) -- raising=False here could
  silently mask the symbol being renamed/removed, letting the test keep
  'passing' against a mock that no longer matches production code.

Addresses Copilot review feedback on PR NousResearch#66076, where this code
incorrectly also appeared due to a branch-history mistake (see that
PR's other review comment) -- fixing it here, in the PR where this
code actually belongs.
Reverts the Host header widening introduced in 2c3d9b3bd per @Kinkoolino-Hermes'
review on PR NousResearch#65965. The original intent was to let browsers send Origin across
reverse proxies (cloudflared + Cloudflare Access) where Host is rewritten to
localhost upstream. Widening the Host check too is a separate concern that
should land as its own PR with its own justification — not bundled here.

The Host header check now stays gated to the bound loopback host. The Origin
header check still accepts dashboard.public_url when loopback-bound (the
behavior the PR was originally trying to ship).

The change is local to _ws_host_origin_reason(); _host_accepted() is preserved
for the Origin branch and can be moved/reused by the future Host-widening PR.
@bbasketballer75

Copy link
Copy Markdown
Author

Rebased onto current origin/main (c3ffe27).

This is the same narrowing fix @Kinkoolino-Hermes' local-validation pass flagged Host-header widening on. Base in the previous reply (95d303138) is 221 commits behind now; the host_accepted() reversal that limits the change to the Origin header only is preserved. Clean rebase, no conflicts in hermes_cli/web_server.py or the new test_web_server_host_header.py tests.

Re-review welcome — particularly on whether the narrowing still satisfies the original "public_url as a valid WebSocket Origin" requirement.

@alt-glitch alt-glitch added P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades and removed P2 Medium — degraded but workaround exists P3 Low — cosmetic, nice to have labels Jul 29, 2026
@alt-glitch alt-glitch added P2 Medium — degraded but workaround exists needs-decision Awaiting maintainer decision before any implementation labels Jul 29, 2026
…eview

Per @Kinkoolino-Hermes's review: the public_url relaxation applies to
Origin only, Host stays gated to bound_host unchanged. These two cases
lock that in -- Host=public_url-host must still be rejected whether
Origin is the ordinary bound-loopback value or the public_url host too,
proving rejection is driven by the unwidened Host check specifically,
not by Origin happening to also mismatch.
@bbasketballer75

Copy link
Copy Markdown
Author

@Kinkoolino-Hermes — added the two rejection cases you asked for: test_public_host_header_still_rejected_with_bound_origin and test_public_host_header_still_rejected_with_public_origin, both confirming the Host header stays gated to bound_host with zero widening (only Origin gets the public_url relaxation), regardless of what Origin is set to. All 5 tests in TestWebSocketPublicUrlOrigin pass. Re-review welcome.

@alt-glitch alt-glitch added duplicate This issue or pull request already exists and removed needs-decision Awaiting maintainer decision before any implementation labels Jul 31, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Duplicate of #62639 — both patches accept the configured dashboard.public_url Origin for a loopback-bound dashboard while retaining the Host-header guard.

@alt-glitch alt-glitch added needs-decision Awaiting maintainer decision before any implementation and removed duplicate This issue or pull request already exists labels Jul 31, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Correction: related to #62639, not a duplicate. Both permit a configured public dashboard origin behind a loopback proxy, but current #65965 matches the configured host while #62639 requires an exact scheme-and-netloc origin policy. A maintainer should choose the contract.

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) needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users 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.

6 participants