Skip to content

fix(dashboard): accept read batches behind an HTTPS proxy - #2250

Open
bmdavis419 wants to merge 1 commit into
UsefulSoftwareCo:v2from
davis7dotsh:dashboard-batch-https-origin
Open

bmdavis419 wants to merge 1 commit into
UsefulSoftwareCo:v2from
davis7dotsh:dashboard-batch-https-origin

Conversation

@bmdavis419

Copy link
Copy Markdown

Behind a proxy that ends TLS, such as Caddy, Cloudflare Tunnel, Tailscale Serve or Railway's edge in front of the self-host image, the dashboard fails reads at random. An app's overview shows "Unable to complete this request", the header shows "Could not reach the server", and a reload brings the page back until the next client-side navigation.

The dashboard sends reads that start together as one POST /api/dashboard/batch. sameOrigin in packages/dashboard-start/src/implementation/batch-host.ts admitted a batch only when the page's Origin equalled HttpServerRequest.toURL(request).origin, which Effect rebuilds from Host and X-Forwarded-Proto. The image's Go host reaches workerd over plain HTTP, and its ReverseProxy.Rewrite drops the outer proxy's X-Forwarded-Proto, so that URL is always http://<host> while the page is https://<host>. Every batch got an empty 403, and every read in it failed.

The browser already knows whether the page and the URL it sends the batch to share an origin, and reports it in Sec-Fetch-Site, which no page can set. sameOrigin now lets that header decide when it is present: same-origin passes, and any other value (same-site, cross-site, none) is refused even when Origin matches. Without the header (plain-HTTP hosts other than loopback, browsers without Fetch Metadata), the exact Origin check applies as before. This is the rule of Go's net/http.CrossOriginProtection, slightly stricter: none is refused, a request with neither Sec-Fetch-Site nor Origin is refused, and the fallback compares the whole origin rather than the host. Executor already reads Sec-Fetch-Site as a veto (telemetry.ts, mcp-approvals.ts, mcp-auth's oauth.ts); this is the first place same-origin admits a request.

Thanks to zackleman for #2246, which fixed this in the Go host and showed every batch failing on Railway. That route has the host claim X-Forwarded-Proto: https whenever BETTER_AUTH_URL is https, which also refuses batches for anyone who opens such a server over plain HTTP (http://localhost:4400, a LAN address), changes the URL toURL builds for every request (the server-rendered document request, the in-process origin), and has the host set a forwarding header from configuration. Comparing with the configured origin, as requireUserLive does, would instead refuse batches on every other hostname of the same instance (a tailnet name beside the public one), although the same reads sent one at a time are accepted there. This change touches only the batch check: toURL, the Go host and layerWithBatches are unchanged, and Cloud's batches from browsers that send Sec-Fetch-Site no longer depend on Cloudflare's X-Forwarded-Proto.

Fixes #2199

Evidence

  • dashboardReadBatches now sends the batch a browser behind a TLS proxy sends (Origin: https://<host>, Sec-Fetch-Site: same-origin) and expects 200 with the member's own viewer read, then 403 for same-site, cross-site and none with the target's own origin.
    • Before the fix, on self-host: expected 403 to be 200 at proxied.status.
    • With a union rule instead (same-origin or a matching Origin): same-site: expected 200 to be 403. The loop pins the strict rule.
    • After the fix: passes on self-host and on managed local Cloud.
  • Other scenarios that send batches also pass with the change:
    • Self-host: dashboardBuildChange, dashboardRequestVolume, serverRenderedDashboard, serverRenderedAccess, queryRefresh, retainedReturn, membersRefresh.
    • Cloud: dashboardRequestVolume, serverRenderedDashboard, queryRefresh, retainedReturn, membersRefresh, browserConnectionFailures, billingPolling.
  • bun run check passes.
  • A real browser over real TLS: Chromium, then Caddy with tls internal, then a throwaway container with fresh data and BETTER_AUTH_URL=https://executor.test:9443.
    • 2.0.0-beta.12: every dashboard batch got 403. The browser sent Origin: https://executor.test:9443 and Sec-Fetch-Site: same-origin. After a client-side navigation, the app overview and Connections showed "Unable to complete this request". A second hostname for the same container also got 403.
    • An image built from this branch: every batch got 200, the same navigation showed no error, and the second hostname got 200.
  • Through the Go host: curl straight to the image's port, beta.12 against an image built from this branch. Each request carried X-Forwarded-Proto: https, which the host drops. Results were identical for H = the BETTER_AUTH_URL host and for a second hostname with a port (box.tailnet.test:10006):
Request beta.12 This branch
a Origin: https://H, Sec-Fetch-Site: same-origin (a browser behind TLS) 403 200
b Origin: https://H, no Sec-Fetch-Site 403 403
c Origin: http://H, no Sec-Fetch-Site (plain HTTP) 200 200
d Origin: http://H, Sec-Fetch-Site: cross-site 200 403
e Origin: https://H, Sec-Fetch-Site: same-site 403 403
f Origin: https://attacker.example, Sec-Fetch-Site: cross-site 403 403
g Origin: https://attacker.example, no Sec-Fetch-Site 403 403
dashboard-read-batches.spec.ts
dashboard-request-volume.spec.ts
server-rendered-dashboard.spec.ts
server-rendered-access.spec.ts
query-refresh.spec.ts
browser-connection-failures.spec.ts
billing-polling.spec.ts

Merge Danger

Door: two-way. Reverting sameOrigin restores the old rule.

Blast radius: dashboard read batches on self-host and Cloud. Only the check on POST /api/dashboard/batch changes.

  • A request whose Sec-Fetch-Site is present and not same-origin is now refused even when its Origin matches (row d). The dashboard batches only same-origin reads, so no browser flow sends one.
  • Behind a TLS proxy, a browser that sends no Sec-Fetch-Site, or a front proxy that strips Sec-Fetch-*, still gets 403, as before.

🤖 Generated with Claude Code

The dashboard sends reads that start together as one POST /api/dashboard/batch, and the batch
route admitted it only when the page's Origin equalled the URL rebuilt from Host and
X-Forwarded-Proto. Behind a proxy that ends TLS, such as the self-host image behind Caddy,
Cloudflare Tunnel or Railway, that URL is http:// while the page is https://, so every batch got an
empty 403 and the dashboard showed "Could not reach the server" on whichever panels it batched.

The browser's Sec-Fetch-Site now decides when it is present: only same-origin passes, and any other
value is refused even when Origin matches. Without the header, the exact Origin check still applies.

Fixes UsefulSoftwareCo#2199

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant