Skip to content

fix(storage): stop the image pin endpoint pinning anyone's files to our Pinata - #423

Open
QSchlegel wants to merge 1 commit into
preprodfrom
claude/daily-2026-10-25-image-upload-auth
Open

QSchlegel wants to merge 1 commit into
preprodfrom
claude/daily-2026-10-25-image-upload-auth

Conversation

@QSchlegel

Copy link
Copy Markdown
Collaborator

What

POST /api/pinata-storage/image/put (the DRep / wallet avatar upload used by ImgDragAndDrop) had no authentication. Anyone on the internet could pin arbitrary files to the project's Pinata account, burning its quota, and nothing recorded who did it. Its sibling /api/pinata-storage/put was closed in 09a042a. This one was missed.

It also had an unauthenticated disk-filling bug. formidable streamed every request to an OS temp file before the handler looked at it:

  • size up to formidable's 200 MB default;
  • temp files never deleted;
  • the 1 MB limit was checked only once the whole upload was already on disk.

/api/vercel-storage/image/put had the same disk problem. It has no callers and answered 501 only after parsing.

Changes

pinata-storage/image/put.ts checks, in this order:

  1. Rate limit: 20/min per IP.

  2. Auth: a wallet session or a v1 bearer token. This is the same rule as put.ts, and it runs before a byte of the body is read.

  3. Content type must be multipart/form-data, else 415.

  4. Content-Length is required (411) and must be at most 1 MB + 64 KB (413). Node stops reading at the declared length, so this is what bounds the body. A chunked request would otherwise go around formidable's caps, which count part bodies but not part headers. The reviewer measured a 200 MB part header taking about 362 MB of RSS.

  5. Parsing happens in memory (fileWriteStreamHandler) with formidable's caps:

    • only the multipart plugin is enabled, because the octet-stream, JSON and urlencoded plugins ignore the caps;
    • maxFiles: 1, and the file must be in the file field;
    • maxFileSize and maxTotalFileSize are 1 MB.

    Nothing is written to disk.

  6. The upload must declare an image/* type, which is what the dropzone accepts. The type comes from the client, so this blocks casual misuse such as pinning HTML. It does not stop a determined caller.

vercel-storage/image/put.ts now answers 501 without reading the body.

The response contract for the in-app caller is unchanged: { url, cid, id }.

Tests

src/__tests__/pinataImageUpload.test.ts has 24 tests. They use real formidable and real multipart bodies, and only auth, the rate limiter and fetch are mocked. They cover:

  • 401 without a session or with a forged bearer, without reading the body;
  • a valid bearer is accepted;
  • the rate limiter runs first;
  • the pinned bytes and filename are exact;
  • exactly 1 MB passes, 1 MB + 1 gets 413;
  • an oversized Content-Length gets 413 unread, and a chunked upload gets 411 unread;
  • a wrong field name gets 400, a second file 413, a non-image 415;
  • json, octet-stream and urlencoded bodies get 415 unread;
  • a malformed multipart body gets 400, not 500;
  • a Pinata error is passed through;
  • the bodyParser: false config export is present;
  • the vercel route answers 501 unread.

7 of these fail against the old handlers.

Other checks:

  • npx tsc --noEmit: clean.

  • node scripts/run-tests.mjs: 151 suites / 1680 tests passed (2 skipped), plus the ESM project 11 / 97.

  • next build --webpack: OK. Bundle budget: within budget, since only server routes changed.

  • Through a real next start, with a session cookie and a dummy Pinata JWT:

    Request Result
    no auth 401
    2 KB and about 1 MB image reach Pinata (Pinata's 401 for the dummy key)
    3 MB 413
    text/html 415
    JSON 415
    chunked 411

A separate review agent read the diff cold twice. The first pass caught a dropped bodyParser: false export, which would have hung every upload. It also caught the non-multipart plugin bypass and the chunked part-header growth. All three are fixed above.

Not verified / for the maintainer

  • An upload with real Pinata credentials. Only the request to Pinata was checked.
  • A user who connected a wallet but dismissed the authorization modal (no session cookie) now gets 401. ImgDragAndDrop shows only "Failed to upload image." and discards the server's "connect and authorize a wallet" message. Surfacing that message would be a small follow-up.
  • /api/pinata-storage/image/exists and the /api/blockfrost/* proxy are still unauthenticated with no rate limit. Neither is touched here.

Generated by Claude Code

…ur Pinata

POST /api/pinata-storage/image/put had no authentication: anyone could pin
arbitrary files to the project's Pinata account. Its sibling
/api/pinata-storage/put was closed in 09a042a; this one was missed.

It also streamed every request, of any size up to formidable's 200 MB
default, to an OS temp file that was never deleted, and only then checked
the 1 MB limit.

Now: rate limit, then wallet session or v1 bearer (same rule as put.ts),
then multipart-only with a required Content-Length of at most 1 MB + 64 KB,
parsed in memory with formidable's caps. Exactly one file part named
`file`, of an image type. Nothing touches disk.

The unused vercel-storage image route answers 501 before reading the body
instead of after writing it to disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoVc5PAx9wDUyD35Km5417
@vercel

vercel Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
multisig Ready Ready Preview Oct 25, 2026 6:33am UTC

Request Review

This branch was successfully deployed

1 active deployment
Preview — 82599d15 Deployed Oct 25, 2026 by vercel[bot]
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