Repository navigation
fix(dashboard): reject cross-origin and rebinding requests to the API - #423
Merged
Merged
Conversation
The loopback-only dashboard accepted POST /api/runs/:id/retry and POST /api/automations/:id/run with no Host, Origin, or Content-Type check, so any page in the user's browser could fire a simple cross-origin POST, and DNS rebinding could read the API. Add checkDashboardRequest(request, boundPort), applied centrally to every /api request before routing: - Host must be 127.0.0.1, localhost, or [::1] on the bound port (403) - non-GET/HEAD: Origin must be the dashboard's own http loopback origin (missing Origin only with Sec-Fetch-Site: same-origin) (403), and Content-Type must be application/json (415) The Vite dev proxy now rewrites Host and Origin to the API target, and the remote-access docs are updated (same-port SSH tunnels, SSH over Tailscale instead of tailscale serve, Caddy header_up rewrites). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Relax the request guard so existing remote-access setups keep working: - Host: any loopback name on any port (remapped SSH tunnels), or the host of an allowed origin. Writes need Origin == http://<Host> on loopback, or the matching allowed origin. JSON content type still required. X-Forwarded-* is never consulted. - Allowed origins: explicit scheme://host[:port] entries from --allow-origin, DASHBOARD_ALLOWED_ORIGINS, and [dashboard] allowed_origins in workspace.toml (validated at load, startup-only). - Tailscale Serve auto-detection: trust names whose serve handler proxies to this dashboard's port, detected at startup and lazily (at most every 30s) for unknown hosts; Funnel warns loudly. Opt out with tailscale_autodetect = false, DASHBOARD_TAILSCALE_AUTODETECT=0, or --no-tailscale-autodetect. - Revert the Vite proxy rewrite (no longer needed) and restore the remapped-port SSH and Tailscale Serve docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The local dashboard server binds loopback only, but its fetch handler accepted
POST /api/runs/:id/retryandPOST /api/automations/:id/runwith noHost,OriginorContent-Typecheck:http://127.0.0.1:4400and retry runs or trigger automations with the user's repository credentials (CSRF).Hostwas never validated, so DNS rebinding (an attacker domain re-resolving to 127.0.0.1) could also read every GET endpoint: runs, transcripts, logs, artifacts.More POST endpoints are about to land, so the guard has to be central rather than per route. It also must not break the remote-access setups the docs recommend: remapped SSH tunnels, Tailscale Serve, and reverse proxies.
Fix
checkDashboardRequest(request, { allowedOrigins })inpackages/code/src/dashboard-server.tsis a pure exported helper. It runs first for every/apirequest, before routing, so new routes are covered automatically.Host (every method, GET included):
127.0.0.1,localhost,[::1]) on any port is accepted. A rebinding page cannot make a browser send a loopback Host. A page on another localhost port cannot read the API (same-origin policy, no CORS headers), and the Origin rule below stops it from writing. Remapped tunnels such asssh -L 8440:127.0.0.1:4400work with no config.403.Writes (non-GET/HEAD):
Originto equalhttp://<Host>, or to be an allowed origin (for proxies that rewrite Host, like nginx's default).Originto be the matching allowed origin.Sec-Fetch-Site: same-origin.403.Content-Typemust start withapplication/json, else415. This forces a CORS preflight on any cross-origin caller, and the server never approves one.X-Forwarded-*headers are never consulted.Allowed origins (
lib/observability/dashboard-origins.ts) are exactscheme://host[:port]entries. Case, default ports and trailing slashes are normalized. Paths, queries, userinfo and wildcards are rejected. Sources:devintern dashboard --allow-origin <origin>(repeatable)DASHBOARD_ALLOWED_ORIGINS(comma-separated)[dashboard] allowed_origins = [...]inworkspace.toml, used by the worker and bydevintern dashboard --workspace. It is parsed intoDashboardSettings, malformed entries fail the load, and the table is startup-only on hot reload.When the list is non-empty, startup logs it with a warning that anyone who can reach those addresses gets full access, including write actions.
Tailscale Serve auto-detection (
lib/observability/dashboard-tailscale.ts) means existingtailscale servesetups keep working with zero config:tailscale serve status --json(2s timeout). EveryWebentry with a handler whoseProxytargets this dashboard's loopback host and bound port is trusted. Proxy forms are expanded the way tailscale'sexpandProxyArgdoes:4400,localhost:4400,http://127.0.0.1:4400. The trusted origin ishttps://<name>, plus:portwhen the port is not 443.r.Out.Host = r.In.Hostinipn/ipnlocal/serve.go; checked against the source).AllowFunnel) are still trusted, but startup logs a loud public-internet warning.[dashboard] tailscale_autodetect = false,DASHBOARD_TAILSCALE_AUTODETECT=0, or--no-tailscale-autodetect.Other changes:
packages/dashboard-ui: no change needed. The Vite dev proxy forwardsHost: localhost:5173andOrigin: http://localhost:5173, which match.api.tsalready sendsContent-Type: application/jsonon both POSTs.docs/code/dashboard.md: documents the protection, keeps the remapped-port SSH and Tailscale Serve recipes (now noting auto-detection, the opt-out and the explicit fallback), addsallowed_originsto the Caddy example with noheader_uprewriting, and adds an "Allowed origins" section. It notes that allowed origins stop cross-site attacks but do not authenticate anyone; Tailscale ACLs or the proxy's login decide who can reach the dashboard.docs/code/workspaces.md: adds a commented[dashboard]example.run-tests.tsand the e2e environment setDASHBOARD_TAILSCALE_AUTODETECT=0, so test dashboards never shell out to a developer's realtailscale.Tests
packages/code/tests/dashboard-request-guard.test.tscovers:null). TheSec-Fetch-Sitefallback and the 415 rule are tested.X-Forwarded-*is ignored.workspace.tomlsources, including invalid entries and unknown keys.The detector was also run against a real
tailscale serve --bg 4400(tailscale 1.102.3). It detected exactly the machine'shttps://<machine>.<tailnet>.ts.netorigin.Existing
dashboard-api/dashboard-automationstests and the local e2edashboard-api.test.tssend the headers the UI sends, via a newdashboardWriteHeaderse2e helper. The e2e also asserts that a bare POST gets 403.bun run test(code, dashboard-ui, local e2e and all other packages), format, lint and typecheck all pass.🤖 Generated with Claude Code