backnotprop / backnotprop/plannotator
Local plan/review server exposes unauthenticated code-execution endpoints (no Origin/CSRF/auth; SSH auto-binds 0.0.0.0)
- Dominant language
- TypeScript
- Stars
- 8.7k
- Forks
- 649
- Avg merge
- 11h 12m
- Merged PRs (30d)
- 109
Description
## Summary
The plannotator local plan/review server (`Bun.serve`) dispatches every route on `url.pathname` + `req.method` with **no `Origin`/`Referer`/`Sec-Fetch-Site` check, no `Host` allowlist, and no auth token/session secret**. Several routes have state-changing or process-spawning side effects, so a malicious web page (CSRF / DNS-rebinding) or a network peer can drive them. Verified against v0.20.3.
## Affected endpoints
- `POST /api/agents/jobs` → `spawnJob` → `Bun.spawn(command, { cwd, env: { ...process.env } })` — launches a credentialed agent (`codex exec --full-auto`, `claude --permission-mode dontAsk`) in the project cwd.
- `POST /api/ai/session` / `/api/ai/query` — runs the agent SDK; read-only tools already stream file contents back. `POST /api/ai/permission { requestId, allow: true }` grants tool permissions — all on the same unauthenticated server, enabling self-escalation to `Write`/`Bash`.
- `POST /api/approve` / `/api/deny` — decides the plan-mode permission gate; `body.permissionMode` is not whitelisted server-side, so `bypassPermissions` passes through.
## Two attack paths
1. **Browser CSRF / DNS-rebinding (default local mode):** while a review server is open at `http://127.0.0.1:`, a page in another tab issues a credential-less, no-preflight `POST` (e.g. a `text/plain` body) to the routes above. The OS-random ephemeral port is the only obstacle and is defeated by loopback port-scanning or by reading the `~/.plannotator` ready-file.
2. **SSH / remote auto-exposure:** `isRemoteSession()` returns true when `SSH_TTY` or `SSH_CONNECTION` is set, flipping `getServerHostname()` to `0.0.0.0` on the fixed port `19432`. Any host that can route to the box reaches every endpoint — no browser, no CSRF, no port guessing.
## Impact
Unauthenticated, cross-site / network-reachable execution of a credentialed AI agent and bypass of the plan-mode permission gate — running as the developer with their git/gh credentials and workspace write access.
## Suggested fixes
- Require a per-session bearer token (random, carried in the local URL; compared with `timingSafeEqual`) on every state-changing route.
- Reject cross-site requests via an `Origin`/`Sec-Fetch-Site` allowlist and a `Host`-header allowlist (DNS-rebinding defense).
- Do not auto-bind `0.0.0.0` from SSH env vars; require explicit opt-in, and keep auth on even then.
Happy to provide more detail privately if you prefer a security contact over a public issue.
Contributor guide
Research direction
Start at the Bun.serve route dispatcher, then trace spawnJob, the AI session/query/permission routes, approve/deny, and the getServerHostname/isRemoteSession logic. Review request validation and SSH binding against the suggested defenses; done means state-changing routes require session authentication, cross-site and Host checks are enforced, and SSH does not expose 0.0.0.0 without explicit opt-in.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication, backend-api-design, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100