Skip to content

fix(dashboard): reject cross-origin and rebinding requests to the API - #423

Merged
danii1 merged 2 commits into
mainfrom
fix/dashboard-csrf-host-guard
Nov 14, 2026
Merged

danii1 merged 2 commits into
mainfrom
fix/dashboard-csrf-host-guard

Conversation

@danii1

@danii1 danii1 commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The local dashboard server binds loopback only, but its fetch handler accepted POST /api/runs/:id/retry and POST /api/automations/:id/run with no Host, Origin or Content-Type check:

  • Any website open in the user's browser could fire a simple cross-origin POST at http://127.0.0.1:4400 and retry runs or trigger automations with the user's repository credentials (CSRF).
  • Host was 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 }) in packages/code/src/dashboard-server.ts is a pure exported helper. It runs first for every /api request, before routing, so new routes are covered automatically.

Host (every method, GET included):

  • A loopback name (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 as ssh -L 8440:127.0.0.1:4400 work with no config.
  • Otherwise the Host must be the host of an allowed origin (default ports normalized). If not, the request gets 403.

Writes (non-GET/HEAD):

  • A loopback Host requires Origin to equal http://<Host>, or to be an allowed origin (for proxies that rewrite Host, like nginx's default).
  • An allowlisted Host requires Origin to be the matching allowed origin.
  • A missing Origin passes only with Sec-Fetch-Site: same-origin.
  • Anything else gets 403.
  • Content-Type must start with application/json, else 415. 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 exact scheme://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 = [...] in workspace.toml, used by the worker and by devintern dashboard --workspace. It is parsed into DashboardSettings, 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 existing tailscale serve setups keep working with zero config:

  • How it detects: it runs tailscale serve status --json (2s timeout). Every Web entry with a handler whose Proxy targets this dashboard's loopback host and bound port is trusted. Proxy forms are expanded the way tailscale's expandProxyArg does: 4400, localhost:4400, http://127.0.0.1:4400. The trusted origin is https://<name>, plus :port when the port is not 443.
  • Why the Host arrives unchanged: tailscale's serve proxy keeps the incoming Host (r.Out.Host = r.In.Host in ipn/ipnlocal/serve.go; checked against the source).
  • When it runs: once at startup, without blocking. It runs again on demand when a request arrives with an unknown non-loopback Host, at most once every 30s, so a serve config added later starts working.
  • Failures: a missing binary, a non-zero exit or garbage output means no detection. Startup never fails because of it.
  • Funnel: Funnel-published names (AllowFunnel) are still trusted, but startup logs a loud public-internet warning.
  • Opt-out: [dashboard] tailscale_autodetect = false, DASHBOARD_TAILSCALE_AUTODETECT=0, or --no-tailscale-autodetect.

Other changes:

  • packages/dashboard-ui: no change needed. The Vite dev proxy forwards Host: localhost:5173 and Origin: http://localhost:5173, which match. api.ts already sends Content-Type: application/json on 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), adds allowed_origins to the Caddy example with no header_up rewriting, 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.
  • Test isolation: the test preload, run-tests.ts and the e2e environment set DASHBOARD_TAILSCALE_AUTODETECT=0, so test dashboards never shell out to a developer's real tailscale.

Tests

packages/code/tests/dashboard-request-guard.test.ts covers:

  • Loopback: any loopback name on any port works for GET and for a same-origin POST. An Origin that does not match the Host is rejected (another local port, the same port under a different loopback name, https, null). The Sec-Fetch-Site fallback and the 415 rule are tested.
  • Rebinding: rebound and malformed Hosts are rejected, and X-Forwarded-* is ignored.
  • Allowlist matching: an allowlisted Host with its matching https Origin is accepted. An allowlisted Host with the wrong Origin or port is rejected. An allowlisted Origin on a loopback Host is accepted.
  • Allowlist parsing: env, flag and workspace.toml sources, including invalid entries and unknown keys.
  • Tailscale detection: a matching handler is allowed. A handler for a different port, https or a non-loopback target is not. A missing binary or garbage output is a no-op. Lazy refresh respects the rate limit and shares one in-flight probe. Funnel is flagged and warns.
  • Server end to end: the guard runs before dispatch. Explicit and env origins are honored and logged with the access warning. A malformed env value fails startup. Auto-detected serve origins accept reads and writes. Both opt-outs prevent any probe.

The detector was also run against a real tailscale serve --bg 4400 (tailscale 1.102.3). It detected exactly the machine's https://<machine>.<tailnet>.ts.net origin.

Existing dashboard-api / dashboard-automations tests and the local e2e dashboard-api.test.ts send the headers the UI sends, via a new dashboardWriteHeaders e2e 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

danii1 and others added 2 commits November 14, 2026 14:11
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>
@danii1
danii1 merged commit 53ce205 into main Nov 14, 2026
2 checks passed
@danii1
danii1 deleted the fix/dashboard-csrf-host-guard branch November 14, 2026 08:39
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