Skip to content

Refuse oversized image uploads at the PUT - #423

Merged
SunkenInTime merged 3 commits into
mainfrom
t3/upload-size-limit
Nov 1, 2026
Merged

SunkenInTime merged 3 commits into
mainfrom
t3/upload-size-limit

Conversation

@SunkenInTime

@SunkenInTime SunkenInTime commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Nothing changes on screen, so there is no picture. This run on real R2 is the before and after. The "before" row used a limit lowered to 1 MB, for the reason under "How far this got".

Request Before (main) After
URL requested for 1 KB, then PUT a bigger body 200, 2 MB stored at a public URL 403 SignatureDoesNotMatch in 1.0 s for 40 MB, public URL 404
URL requested for 15 MB, the limit, then PUT 40 MB not run on main 403 in 0.6 s, public URL 404
URL requested with no size URL issued refused, INVALID_PAYLOAD
URL requested for 40 MB not run on main (main already refuses a stated size over the limit) refused, INVALID_PAYLOAD
1.95 MB PNG from unchanged 5.1.3 desktop not run on main 200, row active, public URL serves the same bytes
1.95 MB PNG from unchanged 5.1.3 web in Edge not run on main 200, row active, public URL serves the same bytes

ci: convex

Why

The 15 MB picture limit only ran at images:completeUpload, after the bytes had landed. A script holding an upload URL could PUT any size and never call complete. The object then sat at a public URL until the stale sweep removed it about a day later. Reddit traffic is coming and a per-account storage cap is planned, and a cap that counts completed uploads would not see those objects.

What this does

images:generateUploadUrl now requires an exact positive integer byteSize within the limit, and presignR2PutUrl signs that length into the URL (content-length;content-type;host). R2 checks the signature when the PUT arrives, so any other length gets a 403 before anything is stored.

Cloudflare's docs don't say this works. The presigned URL page never mentions Content-Length, and POST policies are unsupported, so I tested it. With a URL signed for 1024 bytes:

PUT R2 answer Stored
exactly 1024 bytes 200 1024 bytes
1025, 2048 or 1023 bytes 403 SignatureDoesNotMatch nothing
chunked with no Content-Length 403 nothing
chunked with Content-Length: 1024 400 nothing
aws-chunked claiming a larger decoded size 403 nothing
Content-Length: 1024 with 2048 bytes sent 200 1024 bytes, the rest is ignored

The check in completeUpload stays. It still catches a limit lowered after a URL was signed.

Old clients

No opt-in is needed. Shipped clients never see the difference, because the response shape is unchanged and Content-Length stays out of requiredHeaders. The argument stays v.optional in the validator and the handler requires it, so the published contract and the generated Dart don't change.

  • Every client since May 2026 sends byteSize and a real length. _putBytesWithProgress and media_bytes_source.dart hash the same at all seven tags from 5.0.0 to 5.1.3, the last Deploy Web commit (9c47335b) and HEAD. The http lockfile entry, Flutter 3.44.4 and protocol 6 also match.
  • Desktop goes through IOClient, which sets contentLength. Web goes through BrowserClient, which sends a Uint8Array body, so the browser fills the header in. Edge sent content-length: 1954430 and the real bucket's CORS rule let it through, since Content-Length isn't a preflight header.
  • URLs signed before this deploy keep working until they expire, 15 minutes at most.

Server order doesn't matter here. An old web tab on the new server takes the same path as the new one.

How far this got

  • npx tsc --noEmit clean. npm run test:convex 447 of 447.
  • The table at the top ran on a private local Convex deployment with real R2 and real Convex functions.
  • The clients were built from the desktop-stable-v5.1.3+110 tag. The only edit is a scratch patch that lets them reach http://127.0.0.1. Desktop ran through tool/drive with a real sign-in. Web ran in Edge on port 8765, through the Upload image dialog.

The dev deployment's R2_BUCKET is icarus-media, the bucket production uses, and its key can't reach any other bucket. So I kept over-limit writes out of it. The R2 probes used bodies of a few KB, the main repro used 2 MB against a limit lowered to 1 MB, and the 40 MB PUTs only ran after the small probes showed R2 refuses without storing. Every object this work wrote there has been deleted by exact key.

Run on dev

After rebasing onto main, I deployed this head to the dev deployment and ran release-configuration builds against it. ICARUS_CLOUD_ENVIRONMENT=development resolves to dev, so neither build needed a URL patch.

Client 259 KB PNG 17 MB PNG
5.1.3, tool/drive/build.ps1, nothing edited row active, public URL serves the same bytes "This image is over 15 MB. Pick a smaller one.", no upload job
5.0.0 row active, public URL serves the same bytes same toast, no upload job

5.0.0 has no driver, so it got a scratch harness: a call after runApp, its sign-in in its own file, and icarus: registration skipped. Its upload queue, media_bytes_source.dart and repository are byte-identical to the tag. The picture went on the canvas through the app's own addImage in an open editor, and the upload was the unchanged queue. The icarus: handler and the shared session file were the same before and after both runs.

With a real test-account token on the same server:

Request Result
URL for 1 KB, PUT 2 KB 403 SignatureDoesNotMatch, nothing stored
URL for 1 KB, PUT 40 MB 403 in 0.2 s, nothing stored
URL for 15 MB, PUT 40 MB 403 in 0.2 s, nothing stored
URL requested with no size, for 40 MB, or for 100.5 bytes refused, INVALID_PAYLOAD, no URL

The 40 MB PUTs only ran after the 2 KB probe left nothing behind. Every test strategy is deleted, and all 7 exact object keys now answer 404 at the public URL.

What isn't covered

  • Desktop 5.0.1 to 5.1.2 were read, not run. 5.0.0 and 5.1.3 were both run, and these tags fall between them with the same upload code.
  • The rerun on 5.0.0 uses a scratch harness, because that tag has no driver. See "Run on dev" for what it changed.
  • A script can still ask for many 15 MB URLs and never complete them, and each object stays until the sweep. This PR fixes how large one object can be. How many an account can hold is the job of the storage cap and rate limits.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no blocking issue was found.

Summary

The PR requires an exact positive upload size within the configured limit and includes that size in the signed PUT URL.

  • Image upload URLs are now bound to an allowed byte size.

Reviews (3) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile

@coderabbitai

coderabbitai Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 1 minute.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c87bb2c9-c9ce-444f-85e7-4e89fbde2fc5
Commits

Reviewing files that changed from the base of the PR and between d211eac and 1feae13.

Files selected for processing: 6
  • convex/imageAssetLifecycle.test.ts
  • convex/imageFileNames.test.ts
  • convex/imageUploadSize.test.ts
  • convex/images.ts
  • convex/lib/r2.ts
  • docs/cloudflare_r2_media_storage.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

The upload URL signed no length, so a script holding one could store any size until the stale sweep. Sign the stated size into the URL: R2 refuses any other length before storing a byte. Every client since May sends the size and a real Content-Length.
The test now checks an independently worked out signature and that the URL is signed for the stated size. R2 refuses a wrong declared length, though not always with a 403, so the docs stop promising one answer.
@SunkenInTime
SunkenInTime merged commit 571a841 into main Nov 1, 2026
14 checks passed
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