security(dashboard): gate /api/* on loopback Host + same-origin write (closes 3 criticals in #184)#188
Merged
Merged
Conversation
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)
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.
Summary
Closes the three Critical findings from #184 (AntFleet two-model review):
dashboard/app/api/skills/[name]/run/route.tsgh workflow run aeon.ymldashboard/app/api/secrets/route.ts(GET)dashboard/app/api/secrets/route.ts(POST / DELETE)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-rebindingattacker.exampleto127.0.0.1, and a browser no-cors POST from any origin tohttp://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.ts—node:testsmoke tests covering stripPort, the env-driven wrapper, the loopback-accept path, both rejection paths, and both env hatchesTwo checks:
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.Origin(withRefererfallback) must resolve to the same loopback set. Defeats no-cors CSRF — amode: "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:
AEON_DASHBOARD_ALLOWED_HOSTS=aeon.local,box.tail-xxx.ts.netAEON_DASHBOARD_ALLOW_ANY_HOST=1Hostupstream. 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— theclaude setup-tokenreassembly loop can splice non-token text intoCLAUDE_CODE_OAUTH_TOKENif 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 runsnpm run dev -- -p $PORTwhich lets Next bind to0.0.0.0. The middleware makes this safe by rejecting non-loopback Hosts, but pinning--hostname 127.0.0.1in 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 clearhintexplaining the env knobs.Test plan
node --test dashboard/lib/security/api-gate.test.tsruns the smoke tests (stdlib-only, no test runner dep added)tsc --noEmitclean (will be exercised by Next's build)./aeon→ confirmcurl -H 'Host: attacker.example' http://127.0.0.1:5555/api/secretsreturns 403 with{"error":"Host not allowed",...}./aeon→ confirmcurl -H 'Origin: http://attacker.example' -X POST http://127.0.0.1:5555/api/skills/morning-brief/runreturns 403 with{"error":"Cross-origin write rejected",...}./aeon→ confirm normal in-browser dashboard usage still worksCloses / refs
🤖 Generated with Claude Code