backnotprop / backnotprop/plannotator

Local plan/review server exposes unauthenticated code-execution endpoints (no Origin/CSRF/auth; SSH auto-binds 0.0.0.0)

Open
#956 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.