Skip to content

security(dashboard): gate /api/* on loopback Host + same-origin write (closes 3 criticals in #184)#188

Merged
aaronjmars merged 1 commit into
mainfrom
security/dashboard-api-gate
May 19, 2026
Merged

security(dashboard): gate /api/* on loopback Host + same-origin write (closes 3 criticals in #184)#188
aaronjmars merged 1 commit into
mainfrom
security/dashboard-api-gate

Conversation

@aaronjmars

Copy link
Copy Markdown
Collaborator

Summary

Closes the three Critical findings from #184 (AntFleet two-model review):

ID Route Finding
C1 dashboard/app/api/skills/[name]/run/route.ts Unauthenticated POST triggers gh workflow run aeon.yml
C2 dashboard/app/api/secrets/route.ts (GET) Unauthenticated read of secret name set
C3 dashboard/app/api/secrets/route.ts (POST / DELETE) Unauthenticated set / delete of any GitHub secret on the repo

Single root cause: every dashboard /api/* route assumes "the OS user owns localhost", and we never validated that. Two paths break the assumption today — a malicious page DNS-rebinding attacker.example to 127.0.0.1, and a browser no-cors POST from any origin to http://localhost:5555/api/.... Both reach the routes; both write GitHub secrets or trigger Actions on the operator's behalf.

Fix

One new middleware that gates every /api/* request:

  • dashboard/middleware.ts — Next middleware mounted on /api/:path*
  • dashboard/lib/security/api-gate.ts — pure validator (no deps)
  • dashboard/lib/security/api-gate.test.tsnode:test smoke tests covering stripPort, the env-driven wrapper, the loopback-accept path, both rejection paths, and both env hatches

Two checks:

  1. Host header must resolve to a loopback variant (127.0.0.1, localhost, ::1, [::1], 0.0.0.0). Defeats DNS rebinding — the browser sends the dialed hostname (attacker.example), we reject it.
  2. On non-GET/HEAD/OPTIONS, Origin (with Referer fallback) must resolve to the same loopback set. Defeats no-cors CSRF — a mode: "no-cors" POST from a malicious page would carry that page's origin in the header.

Two operator hatches preserve LAN / Tailscale / reverse-proxy setups:

Env var Behaviour
AEON_DASHBOARD_ALLOWED_HOSTS=aeon.local,box.tail-xxx.ts.net Extends the loopback allowlist by one or more hostnames (comma-separated; case- and port-insensitive).
AEON_DASHBOARD_ALLOW_ANY_HOST=1 Disables the gate entirely. Intended only for a trusted reverse proxy that terminates Host upstream. Loudly insecure if set without an authenticating proxy in front.

What this PR does not touch

This is the surface fix. AntFleet's report flagged a related H5 in dashboard/app/api/auth/route.ts:81-108 — the claude setup-token reassembly loop can splice non-token text into CLAUDE_CODE_OAUTH_TOKEN if the CLI's output contains a hyphen-only continuation line. It's a real bug worth fixing but is orthogonal to the auth-gate work in this PR, so I'd suggest a follow-up. Happy to send.

Also unfixed here: the dashboard launcher (./aeon) currently runs npm run dev -- -p $PORT which lets Next bind to 0.0.0.0. The middleware makes this safe by rejecting non-loopback Hosts, but pinning --hostname 127.0.0.1 in the launcher would add defense in depth. Worth a tiny follow-up.

Backward compatibility

Pure addition. The default loopback set covers every "just run ./aeon" launch path. Existing operators see no change. Anyone running the dashboard behind a non-loopback hostname today must set one of the two env vars — there's no silent break, the rejection includes a clear hint explaining the env knobs.

Test plan

  • node --test dashboard/lib/security/api-gate.test.ts runs the smoke tests (stdlib-only, no test runner dep added)
  • tsc --noEmit clean (will be exercised by Next's build)
  • Manual: ./aeon → confirm curl -H 'Host: attacker.example' http://127.0.0.1:5555/api/secrets returns 403 with {"error":"Host not allowed",...}
  • Manual: ./aeon → confirm curl -H 'Origin: http://attacker.example' -X POST http://127.0.0.1:5555/api/skills/morning-brief/run returns 403 with {"error":"Cross-origin write rejected",...}
  • Manual: ./aeon → confirm normal in-browser dashboard usage still works

Closes / refs

🤖 Generated with Claude Code

Closes the 3 critical findings AntFleet flagged in #184:
- C1 · /api/skills/[name]/run — unauthenticated POST triggers
  `gh workflow run aeon.yml`
- C2 · /api/secrets GET — unauthenticated read of secret name set
- C3 · /api/secrets POST/DELETE — unauthenticated set / delete of
  any GitHub secret on the repo

Same root cause: every dashboard `/api/*` route assumes "the OS user
owns localhost". Two paths break that assumption — a malicious page
that DNS-rebinds `attacker.example` to `127.0.0.1` and POSTs to the
loopback socket, and a browser cross-origin no-cors POST that bypasses
preflight. Both reach the routes today; both write GitHub secrets or
trigger Actions on the operator's behalf.

Add a single Next middleware (`dashboard/middleware.ts`) that gates
every `/api/*` request through `dashboard/lib/security/api-gate.ts`:

  - Host header must resolve to a loopback variant (127.0.0.1,
    localhost, ::1, [::1], 0.0.0.0) — defeats DNS rebinding.
  - On non-GET/HEAD/OPTIONS, Origin (with Referer fallback) must also
    resolve to a loopback variant — defeats no-cors CSRF.

Two operator hatches preserve LAN/Tailscale/reverse-proxy setups:

  - `AEON_DASHBOARD_ALLOWED_HOSTS=aeon.local,box.tail-xxx.ts.net` —
    extend the allowlist (comma-separated; case + port insensitive).
  - `AEON_DASHBOARD_ALLOW_ANY_HOST=1` — bypass entirely, intended for
    a trusted proxy that owns Host policy. Loudly insecure; not the
    default.

Pure stdlib in `api-gate.ts` (Set + URL parsing); no new deps. Smoke
tests in `api-gate.test.ts` cover stripPort, the env-driven wrapper,
the loopback-accept happy path, both rejection paths, and both env
hatches — run with `node --test dashboard/lib/security/api-gate.test.ts`.

README gains a `### Dashboard access` block under Quick start
documenting the env vars + the two threat-model paths the gate
defeats.

Refs: AntFleet receipts
- AntFleet#1 (comment)
- AntFleet#3 (comment)
- AntFleet#25 (comment)
@aaronjmars
aaronjmars merged commit fa0adc0 into main May 19, 2026
1 check passed
@aaronjmars
aaronjmars deleted the security/dashboard-api-gate branch May 19, 2026 20:33
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