Skip to content

Before going public: an AI tool gets read and write unless it names delete, uploads have limits, and the review's other fixes - #107

Merged
NiallJoeMaher merged 2 commits into
mainfrom
security-before-public
Oct 26, 2026
Merged

NiallJoeMaher merged 2 commits into
mainfrom
security-before-public

Conversation

@NiallJoeMaher

Copy link
Copy Markdown
Contributor

The second readiness review, before the repo goes public: one scope bug that broke a house rule, and the hardening an open repo invites.

An AI tool got delete and the account by default (fixed). Better Auth stores every dynamically registered or CIMD client with every scope Pluck has, whatever it asked for (resolveClientRegistrationScopes), and an authorize with no scope is given all of them. The connector also advertised every scope, and the MCP spec tells clients to request scopes_supported. So a spec-following AI tool was offered library:delete and account, against CLAUDE.md. Now (apps/web/lib/auth.ts):

  • the guard gives a client that isn't ours read and write when it names no scope;
  • it refuses the account scope outright for such a client;
  • it still allows library:delete when a tool names it, which the consent screen then shows;
  • the connector advertises library:read and library:write only (advertisedMetadata).

Checked on a local server: no scope → offline_access library:read library:write; account → 400; named delete → kept. A test in auth-guard.test.ts pins all three. AI tools connected before this may still hold the wider grant.

Smaller fixes

  • Sign-in redirect: next refuses anything a browser reads as another site, like /\evil.com or control characters (lib/same-site.ts, tested).
  • Test switches refused on a public server: ALLOW_PRIVATE_FETCH=1 and AUTH_RATE_LIMIT=off now stop a public production server from starting (instrumentation.ts). Production sets neither.
  • More blocked addresses: page fetches also refuse NAT64, 6to4, 192.0.0.0/24 and 240.0.0.0/4.
  • Digest budget: "Send me one now" allows five a day per person.
  • Pictures only: covers and images are kept only as JPEG, PNG, WebP, GIF or AVIF, never SVG.
  • Size limits (@pluck/blob getObject(key, { maxBytes })):
    • an uploaded file over 50 MB marks its item failed;
    • an import over 500 MB fails;
    • an email over 10 MB is refused before it is downloaded;
    • Omnivore zip entries over 20 MB, or 100 MB of metadata in total, are refused before fflate allocates them;
    • the cover PUT stops reading at 2 MB.
    • The signed PUT URL still binds no length: the shipped iPhone app sends size 1 when it can't read a file's size.
  • npm package: the stdio bridge builds before publishing and has a README, a homepage and an issues link.
  • .gitignore: Claude Code worktrees, personal settings, tsbuildinfo, coverage.

Test, locally on this branch: pnpm typecheck, pnpm lint, pnpm test (441), pnpm e2e (56) and pnpm design pass. Merged on local checks, as Niall asked, while Actions minutes are out.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GZorAwPFkDvfWHxJNqzu9B

NiallJoeMaher and others added 2 commits October 26, 2026 19:47
…elete, and the review's other security fixes

- An AI tool that names no scope now gets read and write: Better Auth stored every registered client with every
  scope, delete and the account included, and gave an authorize with no scope all of them. The guard narrows it,
  refuses the account scope for anything not ours, and the connector advertises read and write only.
- Sign-in's next path refuses anything a browser reads as another site ("/\evil.com", control characters).
- A public production server refuses ALLOW_PRIVATE_FETCH and AUTH_RATE_LIMIT=off, and page fetches also refuse
  NAT64, 6to4, 192.0.0.0/24 and 240.0.0.0/4.
- "Send me one now" sends at most five digests a day.
- Covers and images are kept only as JPEG, PNG, WebP, GIF or AVIF: an SVG can carry script.
- The stdio bridge builds before it is published, and has a README, homepage and issues link.
- .gitignore: Claude Code worktrees and personal settings, tsbuildinfo, coverage.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZorAwPFkDvfWHxJNqzu9B
Signed-off-by: NiallJoeMaher <nialljoemaher@gmail.com>
…efused, a cover stops at 2 MB

- @pluck/blob: getObject(key, { maxBytes }) refuses an object over the limit
  from its Content-Length, before the body is read (TooLarge).
- A shared file over 50 MB marks the item failed and removes the blob; an
  import over 500 MB fails with that reason. The limits now live next to the
  schemas that declare them (MAX_FILE_BYTES, MAX_IMPORT_BYTES).
- Email-in refuses a message over 10 MB before reading it.
- Omnivore: entries over 20 MB, or metadata over 100 MB in all, are refused
  before fflate allocates them; a page too big to keep is fetched instead.
- Cover PUT checks content-length first and still stops reading at 2 MB.
- vitest resolves the web app's "@/" path so route handlers can be tested.

The signed PUT URL still does not bind a length: the shipped iPhone app
sends size 1 when it cannot read a file's size, and that upload would fail.

Signed-off-by: NiallJoeMaher <nialljoemaher@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZorAwPFkDvfWHxJNqzu9B
@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pluck Ready Ready Preview Oct 26, 2026 7:52pm UTC

Request Review

@NiallJoeMaher
NiallJoeMaher merged commit 7391679 into main Oct 26, 2026
1 of 5 checks passed
@NiallJoeMaher
NiallJoeMaher deleted the security-before-public branch October 26, 2026 19:52
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