I needed an mcp for lexidraw.app

fix(oauth): close High/Medium security audit findings (login CSRF + 5 Medium) #16

Open scarnecchia.net
targeting main from fix/oauth-security-high-medium
Open opened by and assigned to scarnecchia.net
targeting main from fix/oauth-security-high-medium

Summary #

  • Fixes all 6 High/Medium findings from the OAuth vulnerability audit (1 High login CSRF, 5 Medium), each with pinning tests.
  • Anonymous legacy (off) mode is unchanged except checkpoint file permissions.
  • Branch is current with main (includes the paste-callback fallback via a merge).

Changes #

  • HIGH — login CSRF: the OAuth state's appState must equal sha256(tx) (the transaction cookie); mismatch fails closed — 403, no token minted. The cookie stays SameSite=Lax by design: the authorization-server → callback hop is a legitimate cross-site top-level GET.
  • Bounded pending-transaction and login-rate maps: pending tx entries get a 15-min TTL and 5k cap; the rate map is capped at 10k entries and fails open past it (availability over precision — the 20 in-flight cap still bounds concurrent authorize() work).
  • LEXIDRAW_MCP_TRUST_PROXY (true/false/hop count) wired into the exported createMcpApp(cfg), plus a 120/min global fallback login bucket evaluated after per-IP — a flood one IP gets rejected for cannot drain it.
  • Bearer tokens carry a sliding 30-day idle TTL (LEXIDRAW_MCP_TOKEN_TTL_MS; 0 disables → legacy never-expiring). Expiry is fail-closed (idle or corrupt-timestamp records are deleted); lastUsedAt refresh is throttled to one write per 10 min so the auth path stays read-only in the common case. /whoami now reports {label, createdAt, lastUsedAt, expiresAt}.
  • Legacy FileCheckpointStore: 0700 dir / 0600 files via atomic tmp+rename; anonymous-mode behavior otherwise unchanged.
  • Loopback HTTP binds 127.0.0.1 by default; LEXIDRAW_MCP_BIND_HOST overrides; hosted mode keeps all-interfaces (the reverse proxy is the front).
  • Docs: CLAUDE.md renamed to a lean top-level AGENTS.md, with the Lexidraw domain contracts and a consolidated LEXIDRAW_MCP_* env table in a new src/atproto/AGENTS.md. The paste branch's docs-assertion test was dropped at merge (tests pin behavior, not prose).

Validation #

  • npx vitest run → 182/182 passing (merged suite: security + paste-flow tests).
  • npm run build → clean (tsc → vite singlefile → server tsc → bun bundles).
  • npx tsc --noEmit → clean; git log main..HEAD -- README.md → empty.

Review notes #

  • A rejected cross-transaction callback still consumes the single-use state and stores a session before the 403 — unavoidable without forking @atproto/oauth-client, and harmless (no token is minted; sessions are DID-keyed, not cookie-keyed).
  • Deploy notes: in-flight logins from a pre-upgrade server fail once (old states carry no binding — restart the login); idle CLI tokens now expire after 30 days unless LEXIDRAW_MCP_TOKEN_TTL_MS=0.
  • The 10k fail-open rate-map cap is a deliberate, documented availability tradeoff.
Commits 0