Skip to content

Decoding: pixel limit against decompression bombs, robustness fixes - #80

Merged
matmen merged 1 commit into
masterfrom
decode-limits
Oct 16, 2026
Merged

matmen merged 1 commit into
masterfrom
decode-limits

Conversation

@matmen

@matmen matmen commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Adds protection against decompression bombs and fixes the crashes and hangs I found by fuzzing the decoders and the public API.

maxPixels

DecodeOptions.maxPixels and AnimationDecodeOptions.maxPixels default to 268,402,689 (16383², the same as sharp's limitInputPixels). Infinity disables the limit. Each decoder checks the dimensions it reads before allocating anything that large, and throws a RangeError ("Image too large: …") if they are over the limit:

  • Covered decoders: PNG, APNG, JPEG, TIFF, WebP (VP8, VP8L, VP8X/animation), TGA and GIF.
  • Animations: every frame is composited onto a full canvas, so the limit applies to frames × canvas. For GIFs, the color indices of the frames are also counted while parsing.

Before this PR, these files killed the process or used gigabytes of memory:

file size before after
JPEG, SOF 65500×65500 250 B killed (memory) rejected in 1 ms
GIF, 4000×4000 screen, 5000 one-pixel frames 115 kB killed rejected in 72 ms
GIF/APNG/TGA/TIFF at 65535² < 100 B RangeError (allocation) rejected in 1 ms
TIFF 278×374 with SamplesPerPixel = 57859 (from the fuzzer) 314 kB killed (6 GB) rejected in 1 ms

Other fixes

  • TIFF: raw samples may take at most 8 bytes per allowed pixel, and a tile at most 4× the image (at least 1 Mpx).
  • JPEG: at most 4 components. Each component allocates coefficients for the whole image.
  • Damaged data: a RangeError from reading past the end of the data becomes "Image: damaged or truncated image data", with the original error as cause. Exceeded limits stay RangeErrors (internally a LimitError).
  • blur/sharpen: blur(Infinity) used to hang and now throws. blur(1e9) used to take minutes; the box window is now computed in constant time, so it takes as long as a small radius. The results for normal radii are unchanged.

PNG memory and speed

For a zlib bomb within the limit (8000×8000 from 250 kB):

before after
Image.decode, peak memory 1272 MB 785 MB
Animation.decode, time 5.8 s 0.57 s
  • Rows are unfiltered in place in the inflated buffer. Each row's output position is always before its input, so this is safe. Interlaced images keep the old path.
  • Image.decode keeps the decoded pixels as its bitmap instead of copying them.
  • In Animation.decode, still PNGs take the image path. In APNG compositing, frames with blend mode "source" copy whole rows.

Testing

  • Corpus: every image (1,600 files) decodes to the same pixels as before, with Image.decode and Animation.decode alike. The only change is gif-rs's stress test issue-101-infinite-empty-loop.gif (6727×65351 = 440 Mpx), which the limit now rejects.
  • Fuzzing: about 9,000 damaged variants of corpus images (truncated, random bytes, header bytes) went through Image.decode, Animation.decode and decode(). Nothing crashed or ran out of memory, and no call took more than 2 seconds.
  • Bad parameters: every public method was called with NaN, ±Infinity, negative numbers, 0, 0.5, 1e9, 2³¹, strings, null, objects and arrays. Nothing hangs, no call takes more than 1 s or 500 MB, and no resulting image is inconsistent.
  • tests/regressions.js: bombs per format (built in the test), maxPixels including Infinity and onlyFirstFrame, damaged data, and blur(1e9) / blur(Infinity).

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ

- DecodeOptions.maxPixels / AnimationDecodeOptions.maxPixels (default
  16383², like sharp): every decoder checks the size it read before
  allocating (PNG, APNG, JPEG, TIFF, WebP/VP8/VP8L, TGA, GIF); animations
  count every frame's canvas, GIFs also the frames' indices while
  parsing. A 250-byte JPEG of 65500² and a 115 kB GIF with 5000 frames on
  4000x4000 killed the process before.
- TIFF: raw samples at most 8 bytes per pixel of the limit, tiles at most
  4 times the image (a damaged SamplesPerPixel took 6 GB).
- JPEG: at most 4 components (each took memory the size of the image).
- RangeErrors from damaged data become "Image: damaged or truncated
  image data" (cause kept); exceeded limits stay RangeErrors (LimitError).
- PNG: rows unfiltered in place, Image.decode keeps the decoded pixels
  (8000x8000: 1272 -> 785 MB), still PNGs in Animation.decode decode like
  images (5.8 s -> 0.57 s), APNG frames that replace pixels copy rows.
- blur/sharpen: infinite radii throw instead of hanging, box windows are
  summed in time independent of the radius (blur(1e9) took minutes).

Found with fuzzing (damaged corpus images, crafted bombs per format, bad
parameters to every public method); decoded pixels of the whole corpus
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
@matmen
matmen merged commit e511d22 into master Oct 16, 2026
13 of 14 checks passed
@matmen
matmen deleted the decode-limits branch October 16, 2026 22:29
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