From de8612a07fd934237556a620329a6d31b174f625 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 22:27:54 +0000 Subject: [PATCH] decoding: pixel limit against decompression bombs, robustness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 15 +++++++++ README.md | 2 ++ src/animation.ts | 38 ++++++++++++++++----- src/codecs/gif.ts | 7 +++- src/codecs/jpeg.ts | 8 ++++- src/codecs/png.ts | 33 ++++++++++++++----- src/codecs/tga.ts | 4 ++- src/codecs/tiff.ts | 8 ++++- src/codecs/vp8.ts | 4 ++- src/codecs/vp8l.ts | 5 ++- src/codecs/webp.ts | 21 +++++++----- src/gif.ts | 4 +-- src/image.ts | 38 +++++++++++++-------- src/index.ts | 8 ++--- src/util/blur.ts | 22 +++++++++---- src/util/limits.ts | 57 ++++++++++++++++++++++++++++++++ tests/regressions.js | 78 +++++++++++++++++++++++++++++++++++++++++++- 17 files changed, 293 insertions(+), 59 deletions(-) create mode 100644 src/util/limits.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9032e4d..8d40a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ character, and decomposes characters the fonts lack where one has their parts, like HarfBuzz. - Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are kept per font and size across calls. +- `DecodeOptions.maxPixels` and `AnimationDecodeOptions.maxPixels` (default 268,402,689, that's 16383², like sharp): + larger images, and animations whose frames take more pixels together, throw a RangeError before anything that size + is allocated. Tiny files declaring huge images (decompression bombs) could take gigabytes or kill the process: a + 250-byte JPEG of 65500x65500, a 115 kB GIF with 5000 frames on a 4000x4000 screen. `maxPixels: Infinity` lifts the + limit. TIFFs also can't take more than 8 bytes of samples per pixel of the limit, or tiles much larger than the + image. ### Performance @@ -42,6 +48,10 @@ 82 instead of 95 ms), with identical output: the predictors run as one loop per mode instead of a switch per pixel, residual costs come from a table, and the LZ77 search hashes every pixel pair once. +- Decoding PNGs takes less memory (an 8000x8000 RGBA image: 785 instead of 1272 MB at most): the rows are unfiltered in + place, and `Image.decode` keeps the decoded pixels instead of copying them. `Animation.decode` decodes still PNGs like + images (10 times as fast for large ones), and composites APNG frames that replace pixels row by row. + ### Fixes - The outline of `Image.renderText` (`strokeWidth`) showed through translucent text, it now only shows around the @@ -56,6 +66,11 @@ of a RangeError, and text larger than 2^28 pixels throws. - Glyph outlines reaching far outside their bitmap rasterize correctly (the parts outside used to change the slope of the edges inside). +- Damaged images throw "Image: damaged or truncated image data" (with the original RangeError as its cause) instead + of RangeErrors from reading past their end. JPEGs with more than 4 components are rejected (each took memory the size + of the image). +- `blur` and `sharpen` hung with an infinite radius, they throw a RangeError now; huge finite radii take as long as + small ones (`blur(1e9)` took minutes). ## 3.2.1 diff --git a/README.md b/README.md index 799403c..1bbc9dc 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too) - WebPs (lossy, lossless and with alpha; bit-exact with libwebp) - TGAs (color mapped, truecolor and greyscale; uncompressed and run length encoded) - JPEGs, TIFFs, PNGs and WebPs are rotated as their Exif (or, for PNGs, XMP) orientation says + - images larger than 268 megapixels (`maxPixels`) are rejected before decoding, so small files declaring huge images + can't exhaust the memory - [Decoding animations](https://imagescript.matmen.dev/classes/Animation.html#decode) (GIF, APNG and WebP) with all frames, durations and disposal modes - [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths, diff --git a/src/animation.ts b/src/animation.ts index d47cfbc..58bea11 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -8,13 +8,14 @@ import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; import { isOptions } from './util/options.ts'; +import { MAX_PIXELS, decoding } from './util/limits.ts'; // loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG and WebP count plays (0 forever) const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1; const fromPlays = (plays: number): number => plays === 0 ? -1 : plays - 1; // frames decoded fully composited, as full canvas frames -function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array, duration: number }>): Frame[] { +function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array | Uint8ClampedArray, duration: number }>): Frame[] { return frames.map(({ pixels, duration }) => { const frame = new Frame(width, height, duration, 0, 0, Frame.DISPOSAL_BACKGROUND); // the composited frames are copies already, the frame can keep them @@ -33,6 +34,12 @@ export interface AnimationOptions { export interface AnimationDecodeOptions { /** Whether to stop decoding after the first frame. Default: `false` */ onlyFirstFrame?: boolean; + /** + * The most pixels to decode, of all frames together (every frame is a full canvas): larger animations throw a + * RangeError before anything that size is allocated, so small files declaring huge animations (decompression + * bombs) can't exhaust the memory. Default: `268402689` (16383², like sharp), `Infinity` for no limit + */ + maxPixels?: number; } /** Options of {@link Animation.encodeWebP}, and those all animation encoders have */ @@ -330,31 +337,46 @@ export class Animation extends Array { */ static decode(input: BinaryData, onlyExtractFirstFrame?: boolean): Promise; static async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise { - const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions; + return decoding(() => Animation.__decode__(input, onlyFirstFrameOrOptions)); + } + + /** @internal */ + private static async __decode__(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions): Promise { + const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; const data = view(input); const type = ImageType.getType(data); - if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation); + if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation, maxPixels); if (type === 'webp') { - const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame); + const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); const frames = canvasFrames(decoded.width, decoded.height, webp.composite(decoded)); return new Animation(frames, fromPlays(decoded.loopCount)); } + // (still PNGs decode like images, keeping their orientation as it is like before) + if (type === 'png' && !png.isAnimated(data)) { + const image = await Image.decode(data, { maxPixels, applyOrientation: false }); + return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), -1); + } if (type === 'png') { - const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame); + const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); const frames = canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)); return new Animation(frames, decoded.frames.length > 1 ? fromPlays(decoded.plays) : -1); } - if (type) return new Animation([Frame.from(await Image.decode(data), 0)]); + if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels }), 0)]); throw new Error('Unsupported image type'); } /** @internal */ - static __fromGIF__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T): T { - const decoded = gifCodec.decode(data, onlyExtractFirstFrame); + static __fromGIF__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T, maxPixels = MAX_PIXELS): Promise { + return decoding(() => Animation.__gifFrames__(data, onlyExtractFirstFrame, Class, maxPixels)); + } + + /** @internal */ + private static __gifFrames__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T, maxPixels: number): T { + const decoded = gifCodec.decode(data, onlyExtractFirstFrame, maxPixels); const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay })); return new Class(canvasFrames(decoded.width, decoded.height, frames), decoded.loopCount); } diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index da35c29..21f86f9 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -1,4 +1,5 @@ import { quantize } from './quantize.ts'; +import { MAX_PIXELS, PixelBudget, checkPixels } from '../util/limits.ts'; // ImageScript disposal modes (any, keep, previous, background) <-> GIF disposal methods (unspecified, none, background, previous) const DISPOSAL_TO_GIF = [0, 1, 3, 2]; @@ -206,7 +207,7 @@ function writeSubBlocks(out: ByteWriter, data: Uint8Array): void { * @param {boolean} [onlyFirstFrame=false] * @return {{width: number, height: number, loopCount: number, frames: GIFFrame[]}} */ -export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF { +export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): DecodedGIF { const reader = new ByteReader(data); const signature = reader.string(6); @@ -222,6 +223,8 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF { const frames: DecodedGIFFrame[] = []; let loopCount = 0; let control: GraphicControl | null = null; + // the frames' color indices, and later a canvas per frame + const budget = new PixelBudget(maxPixels); try { parse(); @@ -230,6 +233,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF { if (err !== UNEXPECTED_END) throw err; } + checkPixels(width, height, maxPixels, Math.max(1, frames.length)); return { width, height, loopCount, frames }; function parse(): void { @@ -287,6 +291,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false): DecodedGIF { const minCodeSize = reader.u8(); const compressed = readSubBlocks(reader); + budget.take(fw, fh); const indices = lzwDecode(compressed, minCodeSize, fw * fh); const { dispose = 0, delay = 0, transparentIndex = -1 } = control || {}; diff --git a/src/codecs/jpeg.ts b/src/codecs/jpeg.ts index 9e45e1c..710de67 100644 --- a/src/codecs/jpeg.ts +++ b/src/codecs/jpeg.ts @@ -5,6 +5,7 @@ * (Copyright 2014 Mozilla Foundation, Apache License 2.0). See THIRD_PARTY_NOTICES.md */ +import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; import { orientation as exifOrientation } from './exif.ts'; import { concat } from '../util/mem.ts'; @@ -60,6 +61,8 @@ interface Frame { export interface DecodeOptions { /** overrides the color transform detected from JFIF/Adobe markers (used by TIFF) */ transform?: 'ycc' | 'rgb' | 'cmyk' | null; + /** the most pixels to decode */ + maxPixels?: number; } function clamp8(v: number): number { @@ -79,7 +82,7 @@ function clamp8(v: number): number { * @param {{transform?: 'ycc'|'rgb'|'cmyk'}} [options] overrides the color transform detected from JFIF/Adobe markers (used by TIFF) * @return {{width: number, height: number, pixels: Uint8Array}} */ -export function decode(data: Uint8Array, { transform }: DecodeOptions = {}): { width: number, height: number, pixels: Uint8Array, orientation: number } { +export function decode(data: Uint8Array, { transform, maxPixels = MAX_PIXELS }: DecodeOptions = {}): { width: number, height: number, pixels: Uint8Array, orientation: number } { if (data[0] !== 0xff || data[1] !== 0xd8) throw new Error('JPEG: invalid signature'); const quantizationTables: Int32Array[] = []; @@ -160,6 +163,7 @@ export function decode(data: Uint8Array, { transform }: DecodeOptions = {}): { w case 0xc0: case 0xc1: case 0xc2: { // SOF baseline, extended, progressive if (frame) throw new Error('JPEG: multiple frames are not supported'); if (data[start] !== 8) throw new Error(`JPEG: ${data[start]}-bit precision is not supported`); + checkPixels(u16(start + 3), u16(start + 1), maxPixels); frame = createFrame(marker === 0xc2, u16(start + 1), u16(start + 3), data.subarray(start + 5, end)); break; } @@ -235,6 +239,8 @@ function createFrame(progressive: boolean, height: number, width: number, compon } if (!width || !sampling.length) throw new Error('JPEG: invalid dimensions'); + // each component takes coefficients for the whole image (grey, YCbCr/RGB and CMYK have up to 4) + if (sampling.length > 4) throw new Error(`JPEG: ${sampling.length} components are not supported`); const hmax = Math.max(...sampling.map(c => c.h)); const vmax = Math.max(...sampling.map(c => c.v)); diff --git a/src/codecs/png.ts b/src/codecs/png.ts index 91adacd..def8a5a 100644 --- a/src/codecs/png.ts +++ b/src/codecs/png.ts @@ -1,4 +1,5 @@ import { crc32 } from './crc.ts'; +import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; import { concat } from '../util/mem.ts'; import { encode as utf8, decode as utf8Decode } from '../util/utf8.ts'; import { orientation as exifOrientation, xmpOrientation } from './exif.ts'; @@ -559,7 +560,7 @@ async function decodeImageData(p: Parsed, chunks: Uint8Array[], width: number, h const rowBytes = Math.ceil(pw * bitsPerPixel / 8); const size = ph * rowBytes; - const pixels = unfilter(data, dataOffset, ph, rowBytes, bpp); + const pixels = unfilter(data, dataOffset, ph, rowBytes, bpp, !interlace); dataOffset += size + ph; toRGBA(pixels, rowBytes, pw, ph, rgba, { row: y => ((ys + y * dy) * width + xs) * 4, step: dx * 4 }); @@ -574,9 +575,10 @@ async function decodeImageData(p: Parsed, chunks: Uint8Array[], width: number, h * The orientation comes from the eXIf chunk, or, if there is none, from the Exif text ImageMagick wrote before eXIf * existed ("Raw profile type exif") or from the XMP packet */ -export async function decode(array: Uint8Array): Promise { +export async function decode(array: Uint8Array, maxPixels = MAX_PIXELS): Promise { const p = parse(array); const { width, height } = p.header; + checkPixels(width, height, maxPixels); const pixels = await decodeImageData(p, p.idat, width, height); return { width, height, pixels, orientation: p.exif ? exifOrientation(p.exif) : await textOrientation(p.text) }; } @@ -610,9 +612,11 @@ export function isAnimated(array: Uint8Array): boolean { * a single frame * @return plays: 0 = forever */ -export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[] }> { +export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[] }> { const p = parse(array); const { width, height } = p.header; + // every frame is composited onto a canvas of its own + checkPixels(width, height, maxPixels, !p.animation || p.animationError || onlyFirstFrame ? 1 : Math.max(1, p.frames.length)); // broken animations show the default image, like browsers do if (!p.animation || p.animationError) { @@ -647,18 +651,29 @@ export function compositeAnimation({ width, height, frames }: { width: number, h for (const f of frames) { const previous = f.dispose === 2 ? canvas.slice() : null; - for (let y = 0; y < f.height; y++) + for (let y = 0; y < f.height; y++) { + // replacing: whole rows + if (f.blend === 0) { + canvas.set(f.pixels.subarray(y * f.width * 4, (y + 1) * f.width * 4), ((f.y + y) * width + f.x) * 4); + continue; + } for (let x = 0; x < f.width; x++) { const s = (y * f.width + x) * 4, d = ((f.y + y) * width + f.x + x) * 4; const sa = f.pixels[s + 3], da = canvas[d + 3]; - if (f.blend === 0 || sa === 255 || da === 0) { - if (f.blend === 0 || sa !== 0) canvas.set(f.pixels.subarray(s, s + 4), d); + if (sa === 255 || da === 0) { + if (sa !== 0) { + canvas[d] = f.pixels[s]; + canvas[d + 1] = f.pixels[s + 1]; + canvas[d + 2] = f.pixels[s + 2]; + canvas[d + 3] = sa; + } } else if (sa !== 0) { const u = sa * 255, v = (255 - sa) * da, al = u + v; for (let c = 0; c < 3; c++) canvas[d + c] = (f.pixels[s + c] * u + canvas[d + c] * v) / al | 0; canvas[d + 3] = al / 255 | 0; } } + } output.push({ pixels: canvas.slice(), duration: f.duration }); @@ -718,8 +733,10 @@ function hexProfile(text: string): Uint8Array { return out; } -function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: number, bpp: number): Uint8Array { - const out = new Uint8Array(height * rowBytes); +// undoes the row filters; in place writes the rows over the data itself (every row ends up before where its data was, +// and the data is read ahead of what's written) +function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: number, bpp: number, inPlace = false): Uint8Array { + const out = inPlace ? data.subarray(offset, offset + height * rowBytes) : new Uint8Array(height * rowBytes); const first = Math.min(bpp, rowBytes); // the first row and the first pixel of every row have zero neighbours, those are handled by separate loops diff --git a/src/codecs/tga.ts b/src/codecs/tga.ts index c416d0c..1646297 100644 --- a/src/codecs/tga.ts +++ b/src/codecs/tga.ts @@ -1,4 +1,5 @@ import { encode as utf8 } from '../util/utf8.ts'; +import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; // Truevision TGA: color mapped, truecolor and greyscale images, uncompressed or run length encoded @@ -91,10 +92,11 @@ function readColor(d: Uint8Array, o: number, bits: number, alphaBits: number, ou /** * Decodes a TGA into RGBA */ -export function decode(d: Uint8Array): { width: number, height: number, pixels: Uint8Array } { +export function decode(d: Uint8Array, maxPixels = MAX_PIXELS): { width: number, height: number, pixels: Uint8Array } { const h = readHeader(d); if (!h || !isTGA(d)) throw new Error('TGA: invalid header'); const { width, height, depth } = h; + checkPixels(width, height, maxPixels); const base = h.imageType & ~8, rle = (h.imageType & 8) !== 0; const bytesPerPixel = Math.ceil(depth / 8); diff --git a/src/codecs/tiff.ts b/src/codecs/tiff.ts index 48cedbf..5a3713b 100644 --- a/src/codecs/tiff.ts +++ b/src/codecs/tiff.ts @@ -1,5 +1,6 @@ import { decompressAsync } from './zlib.ts'; import * as jpeg from './jpeg.ts'; +import { MAX_PIXELS, checkPixels, checkBytes } from '../util/limits.ts'; const TYPE_SIZES: Record = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, 13: 4, 16: 8, 17: 8, 18: 8 }; @@ -46,7 +47,7 @@ interface SampleReader { (rowOffset: number, n: number): { raw: number, value: number }; } -export async function decode(data: Uint8Array): Promise<{ width: number, height: number, pixels: Uint8Array, orientation: number }> { +export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, pixels: Uint8Array, orientation: number }> { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); let little: boolean; @@ -178,6 +179,11 @@ export async function decode(data: Uint8Array): Promise<{ width: number, height: return data.subarray(offset, Math.min(data.length, offset + count)); }; + checkPixels(width, height, maxPixels); + // tiles may reach past the image, but not by much (a segment's buffer is that large), and samples can take more + // memory than the pixels + checkPixels(segmentWidth, segmentHeight, Math.max(4 * width * height, 1 << 20)); + checkBytes(Math.ceil(width * samplesPerPixel * bps / 8) * height, maxPixels); const pixels = new Uint8Array(width * height * 4); // JPEG compressed segments decode straight to RGBA diff --git a/src/codecs/vp8.ts b/src/codecs/vp8.ts index 288cbfc..b006cda 100644 --- a/src/codecs/vp8.ts +++ b/src/codecs/vp8.ts @@ -4,6 +4,7 @@ * 2010 Google Inc., BSD license), so the output matches libwebp exactly. See THIRD_PARTY_NOTICES.md */ import { KF_BMODE_PROBS, COEFF_UPDATE_PROBS, DEFAULT_COEFF_PROBS, DC_QLOOKUP, AC_QLOOKUP, PCAT } from './vp8-tables.ts'; +import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; /** * Boolean entropy decoder, RFC 6386 section 7 @@ -215,9 +216,10 @@ const clamp255 = (v: number): number => v < 0 ? 0 : v > 255 ? 255 : v; /** * Decodes a VP8 key frame (the payload of a VP8 chunk) into RGBA */ -export function decode(data: Uint8Array): { width: number, height: number, pixels: Uint8Array } { +export function decode(data: Uint8Array, maxPixels = MAX_PIXELS): { width: number, height: number, pixels: Uint8Array } { const { header: h, first: d } = readHeader(data); const { width, height } = h; + checkPixels(width, height, maxPixels); const mbw = (width + 15) >> 4, mbh = (height + 15) >> 4; // planes with a one pixel border on the top and left (127 above, 129 to the left, as the spec defines) diff --git a/src/codecs/vp8l.ts b/src/codecs/vp8l.ts index d366b4d..a77675b 100644 --- a/src/codecs/vp8l.ts +++ b/src/codecs/vp8l.ts @@ -4,6 +4,8 @@ * Revised BSD License (see THIRD_PARTY_NOTICES.md) */ +import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; + /** decoded VP8L image: ARGB pixels (0xAARRGGBB) */ export interface VP8LImage { width: number; @@ -484,8 +486,9 @@ export function readHeader(data: Uint8Array): { width: number, height: number, h /** * Decodes a VP8L bitstream (the payload of a VP8L chunk) */ -export function decode(data: Uint8Array): VP8LImage { +export function decode(data: Uint8Array, maxPixels = MAX_PIXELS): VP8LImage { const { width, height, hasAlpha } = readHeader(data); + checkPixels(width, height, maxPixels); const reader = new BitReader(data, 5); return { width, height, hasAlpha, argb: decodeStream(reader, width, height) }; } diff --git a/src/codecs/webp.ts b/src/codecs/webp.ts index 673e0d3..651b2af 100644 --- a/src/codecs/webp.ts +++ b/src/codecs/webp.ts @@ -7,6 +7,7 @@ import * as vp8l from './vp8l.ts'; import * as vp8 from './vp8.ts'; import { orientation as exifOrientation } from './exif.ts'; +import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; const fourCC = (s: string): number => s.charCodeAt(0) | s.charCodeAt(1) << 8 | s.charCodeAt(2) << 16 | s.charCodeAt(3) << 24; const RIFF = fourCC('RIFF'), WEBP = fourCC('WEBP'); @@ -71,12 +72,12 @@ const u24 = (d: Uint8Array, o: number): number => d[o] | d[o + 1] << 8 | d[o + 2 /** * Decodes a frame's image data: a VP8L chunk, or a VP8 chunk with an optional ALPH chunk before it */ -function decodeBitstream(list: Chunk[]): { width: number, height: number, pixels: Uint8Array } { +function decodeBitstream(list: Chunk[], maxPixels: number): { width: number, height: number, pixels: Uint8Array } { let alpha: Chunk | null = null; for (const chunk of list) { if (chunk.type === ALPH) alpha ??= chunk; else if (chunk.type === VP8L) { - const { width, height, argb } = vp8l.decode(chunk.data); + const { width, height, argb } = vp8l.decode(chunk.data, maxPixels); // ARGB -> RGBA in memory, one 32-bit word per pixel (written little endian, the byte order doesn't matter) const pixels = new Uint8Array(width * height * 4); const out = new DataView(pixels.buffer); @@ -86,7 +87,7 @@ function decodeBitstream(list: Chunk[]): { width: number, height: number, pixels } return { width, height, pixels }; } else if (chunk.type === VP8) { - const image = vp8.decode(chunk.data); + const image = vp8.decode(chunk.data, maxPixels); if (alpha) applyAlpha(image.pixels, image.width, image.height, alpha.data); return image; } @@ -147,7 +148,7 @@ export function isAnimated(data: Uint8Array): boolean { /** * Decodes a still WebP into RGBA pixels. Animated WebPs decode to their first frame composited on the canvas */ -export function decode(data: Uint8Array): { width: number, height: number, pixels: Uint8Array, orientation: number } { +export function decode(data: Uint8Array, maxPixels = MAX_PIXELS): { width: number, height: number, pixels: Uint8Array, orientation: number } { const list = container(data); if (!list.length) throw new Error('WebP: no image data'); @@ -156,12 +157,12 @@ export function decode(data: Uint8Array): { width: number, height: number, pixel if (exif) orientation = exifOrientation(exif.data); if (list.some(c => c.type === ANMF)) { - const animation = decodeAnimation(data, true); + const animation = decodeAnimation(data, true, maxPixels); const frame = composite(animation)[0]; return { width: animation.width, height: animation.height, pixels: frame.pixels, orientation }; } - const image = decodeBitstream(list); + const image = decodeBitstream(list, maxPixels); if (list[0].type === VP8X && list[0].data.length >= 10) { // the canvas size wins over the bitstream's, they have to match const width = u24(list[0].data, 4) + 1, height = u24(list[0].data, 7) + 1; @@ -173,16 +174,18 @@ export function decode(data: Uint8Array): { width: number, height: number, pixel /** * Decodes an animated WebP into its frames (not composited); still WebPs become a single frame */ -export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false): WebPAnimation { +export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): WebPAnimation { const list = container(data); const header = list[0]; if (!header || header.type !== VP8X || !list.some(c => c.type === ANMF)) { - const { width, height, pixels } = decodeBitstream(list); + const { width, height, pixels } = decodeBitstream(list, maxPixels); return { width, height, loopCount: 0, background: 0, frames: [{ x: 0, y: 0, width, height, duration: 0, blend: false, dispose: false, pixels }] }; } if (header.data.length < 10) throw new Error('WebP: invalid VP8X chunk'); const width = u24(header.data, 4) + 1, height = u24(header.data, 7) + 1; + // every frame is composited onto a canvas of its own + checkPixels(width, height, maxPixels, onlyFirstFrame ? 1 : list.filter(c => c.type === ANMF).length); let loopCount = 0, background = 0; const frames: WebPFrame[] = []; @@ -195,7 +198,7 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false): WebPA } else if (chunk.type === ANMF) { const d = chunk.data; if (d.length < 16) throw new Error('WebP: invalid ANMF chunk'); - const frame = decodeBitstream(chunks(d, 16, d.length)); + const frame = decodeBitstream(chunks(d, 16, d.length), maxPixels); frames.push({ x: u24(d, 0) * 2, y: u24(d, 3) * 2, diff --git a/src/gif.ts b/src/gif.ts index b1c12ff..ee9b08f 100644 --- a/src/gif.ts +++ b/src/gif.ts @@ -43,9 +43,9 @@ export class GIF extends Animation { * @deprecated Use {@link Animation.decode}, which decodes APNGs too */ static override async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise { - const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions; + const { onlyFirstFrame = false, maxPixels } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; const data = view(input); if (!ImageType.isGIF(new DataView(data.buffer, data.byteOffset, data.byteLength))) throw new Error('Unsupported image type'); - return Animation.__fromGIF__(data, onlyExtractFirstFrame, GIF); + return Animation.__fromGIF__(data, onlyFirstFrame, GIF, maxPixels); } } diff --git a/src/image.ts b/src/image.ts index b9adc89..29636a0 100644 --- a/src/image.ts +++ b/src/image.ts @@ -15,6 +15,7 @@ import { resample, BOX, TRIANGLE, CUBIC } from './util/resample.ts'; import { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; import { isOptions } from './util/options.ts'; +import { MAX_PIXELS, decoding } from './util/limits.ts'; /** A color as a 32-bit RGBA integer (`0xRRGGBBAA`) */ export type Color = number; @@ -133,6 +134,12 @@ export interface TextOptions extends TextStyle { export interface DecodeOptions { /** Whether to rotate/mirror JPEGs, TIFFs, PNGs and WebPs as their orientation tag (Exif or XMP) says. Default: `true` */ applyOrientation?: boolean; + /** + * The most pixels to decode: larger images throw a RangeError before anything that size is allocated, so small + * files declaring huge images (decompression bombs) can't exhaust the memory. Default: `268402689` (16383², like + * sharp), `Infinity` for no limit + */ + maxPixels?: number; } /** Text style options of {@link Image.renderText} */ @@ -1344,7 +1351,7 @@ export class Image { * @param radius The blur radius: the gaussian's standard deviation in pixels, like CSS' blur() */ blur(radius: number): this { - if (isNaN(radius) || radius < 0) + if (!Number.isFinite(radius) || radius < 0) throw new RangeError('Invalid blur radius'); if (boxBlurRGBA(this.bitmap, this.width, this.height, radius)) return this; @@ -1367,9 +1374,9 @@ export class Image { sharpen(amount?: number, radius?: number): this; sharpen(amountOrOptions: number | SharpenOptions = 1, radiusArgument = 1): this { const { amount = 1, radius = 1 } = isOptions(amountOrOptions) ? amountOrOptions : { amount: amountOrOptions, radius: radiusArgument }; - if (isNaN(amount) || amount < 0) + if (!Number.isFinite(amount) || amount < 0) throw new RangeError('Invalid sharpen amount'); - if (isNaN(radius) || radius <= 0) + if (!Number.isFinite(radius) || radius <= 0) throw new RangeError('Invalid sharpen radius'); const blurred = premultiply(this.bitmap); @@ -1922,22 +1929,25 @@ export class Image { * @return The decoded image */ static async decode(input: BinaryData, options: DecodeOptions = {}): Promise { - const { applyOrientation = true } = options; + const { applyOrientation = true, maxPixels = MAX_PIXELS } = options; const data = view(input); const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength); - let decoded: { width: number, height: number, pixels: Uint8Array, orientation?: number }; - if (ImageType.isPNG(dataView)) decoded = await png.decode(data); - else if (ImageType.isJPEG(dataView)) decoded = jpeg.decode(data); - else if (ImageType.isTIFF(dataView)) decoded = await tiff.decode(data); - else if (ImageType.isWebP(dataView)) { - if (webp.isAnimated(data)) throw new Error('Animated WebPs have to be decoded with Animation.decode()'); - decoded = webp.decode(data); - } else if (ImageType.isTGA(dataView)) decoded = tga.decode(data); - else throw new Error('Unsupported image type'); + const decoded: { width: number, height: number, pixels: Uint8Array, orientation?: number } = await decoding(() => { + if (ImageType.isPNG(dataView)) return png.decode(data, maxPixels); + if (ImageType.isJPEG(dataView)) return jpeg.decode(data, { maxPixels }); + if (ImageType.isTIFF(dataView)) return tiff.decode(data, maxPixels); + if (ImageType.isWebP(dataView)) { + if (webp.isAnimated(data)) throw new Error('Animated WebPs have to be decoded with Animation.decode()'); + return webp.decode(data, maxPixels); + } + if (ImageType.isTGA(dataView)) return tga.decode(data, maxPixels); + throw new Error('Unsupported image type'); + }); + // the decoded pixels become the bitmap, without a copy const image = new Image(decoded.width, decoded.height); - image.bitmap.set(decoded.pixels); + image.bitmap = decoded.pixels; return applyOrientation && decoded.orientation ? image.__orient__(decoded.orientation) : image; } diff --git a/src/index.ts b/src/index.ts index 9d070f0..82264c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,9 +38,9 @@ export function decode(data: BinaryData, options?: AnimationDecodeOptions & Deco */ export function decode(data: BinaryData, onlyExtractFirstFrame?: boolean): Promise; export function decode(data: BinaryData, onlyFirstFrameOrOptions: boolean | (AnimationDecodeOptions & DecodeOptions) = false): Promise { - const { onlyFirstFrame = false, applyOrientation } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; + const { onlyFirstFrame = false, applyOrientation, maxPixels } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; const type = ImageType.getType(data); - if (type === 'gif') return GIF.decode(data, { onlyFirstFrame }); - if (type === 'webp' && webp.isAnimated(view(data))) return Animation.decode(data, { onlyFirstFrame }); - return Image.decode(data, { applyOrientation }); + if (type === 'gif') return GIF.decode(data, { onlyFirstFrame, maxPixels }); + if (type === 'webp' && webp.isAnimated(view(data))) return Animation.decode(data, { onlyFirstFrame, maxPixels }); + return Image.decode(data, { applyOrientation, maxPixels }); } diff --git a/src/util/blur.ts b/src/util/blur.ts index 03ba4d1..9b80dec 100644 --- a/src/util/blur.ts +++ b/src/util/blur.ts @@ -21,16 +21,21 @@ function boxPass(src: Float32Array, dst: Float32Array, count: number, length: nu for (let line = 0; line < count; line++) { const base = line * lineStride; const first = base, end = base + last; - let r = 0, g = 0, b = 0, a = 0; - - // window around the first value, repeating the edge values - for (let k = -radius; k <= radius; k++) { - const o = k < 0 ? first : Math.min(end, base + k * step); + // window around the first value, repeating the edge values: the first value radius + 1 times, the next ones, + // and the last one for what the window reaches past the end (so the cost doesn't depend on the radius) + let r = (radius + 1) * src[first], g = (radius + 1) * src[first + 1], b = (radius + 1) * src[first + 2], a = (radius + 1) * src[first + 3]; + const inside = Math.min(radius, length - 1); + for (let k = 1, o = base + step; k <= inside; k++, o += step) { r += src[o]; g += src[o + 1]; b += src[o + 2]; a += src[o + 3]; } + const beyond = radius - inside; + r += beyond * src[end]; + g += beyond * src[end + 1]; + b += beyond * src[end + 2]; + a += beyond * src[end + 3]; for (let i = 0, o = base; i < length; i++, o += step) { dst[o] = r * scale; @@ -55,8 +60,11 @@ function boxPassRows(src: Float32Array, dst: Float32Array, width: number, height const sum = new Float64Array(stride); const row = (y: number): number => (y < 0 ? 0 : y >= height ? height - 1 : y) * stride; - for (let k = -radius; k <= radius; k++) { - const o = row(k); + // the window around the first row (see boxPass) + const inside = Math.min(radius, height - 1), last = (height - 1) * stride; + for (let x = 0; x < stride; x++) sum[x] = (radius + 1) * src[x] + (radius - inside) * src[last + x]; + for (let k = 1; k <= inside; k++) { + const o = k * stride; for (let x = 0; x < stride; x++) sum[x] += src[o + x]; } diff --git a/src/util/limits.ts b/src/util/limits.ts new file mode 100644 index 0000000..91c37ed --- /dev/null +++ b/src/util/limits.ts @@ -0,0 +1,57 @@ +// limits against decompression bombs: small files declaring huge images + +/** Thrown when an image exceeds the decode options' maxPixels */ +export class LimitError extends RangeError {} + +/** The default of the decode options' maxPixels: 16383 squared (like sharp's limitInputPixels) */ +export const MAX_PIXELS = 0x3fff * 0x3fff; + +/** + * Throws if decoding an image of the given size would exceed the limit, before anything that size is allocated + * @param count the number of such images (frames) + */ +export function checkPixels(width: number, height: number, limit: number, count = 1): void { + if (!(width * height * count <= limit)) + throw new LimitError(`Image too large: ${count > 1 ? `${count} frames of ` : ''}${width}x${height} pixels exceed the limit of ${limit} (the decode options' maxPixels)`); +} + +/** The most bytes of raw samples per pixel of the limit (4 samples of 16 bits) */ +export const MAX_BYTES_PER_PIXEL = 8; + +/** + * Throws if an image's raw samples (bytes) would exceed what the pixel limit allows, for formats with any number of + * samples per pixel + */ +export function checkBytes(bytes: number, limit: number): void { + if (!(bytes <= limit * MAX_BYTES_PER_PIXEL)) + throw new LimitError(`Image too large: ${bytes} bytes of samples exceed what the limit of ${limit} pixels allows (the decode options' maxPixels)`); +} + +/** Counts the pixels of decoded frames against a limit */ +export class PixelBudget { + private left: number; + private limit: number; + + constructor(limit: number) { + this.limit = this.left = limit; + } + + /** Throws if another frame of the given size would exceed the limit */ + take(width: number, height: number): void { + this.left -= width * height; + if (!(this.left >= 0)) throw new LimitError(`Image too large: its frames exceed the limit of ${this.limit} pixels (the decode options' maxPixels)`); + } +} + +/** + * Runs a decoder: reading past the end of damaged or truncated data throws a RangeError, which becomes an Error saying + * so (with the RangeError as its cause); exceeded limits stay RangeErrors + */ +export async function decoding(decode: () => T | Promise): Promise { + try { + return await decode(); + } catch (err) { + if (err instanceof RangeError && !(err instanceof LimitError)) throw new Error('Image: damaged or truncated image data', { cause: err }); + throw err; + } +} diff --git a/tests/regressions.js b/tests/regressions.js index e0922b6..aee1a28 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -1,4 +1,4 @@ -const {Image, Frame, GIF, TextLayout} = require('..'); +const {Image, Frame, GIF, Animation, TextLayout} = require('..'); const zlib = require('zlib'); const assert = (condition, message) => { @@ -561,4 +561,80 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); } } } + + // decompression bombs: tiny files declaring huge images throw a RangeError before anything that size is allocated, + // maxPixels sets the limit; damaged data throws an error saying so + { + const chunk = (type, body) => { + const out = Buffer.alloc(12 + body.length); + out.writeUInt32BE(body.length); + out.write(type, 4, 'latin1'); + body.copy(out, 8); + out.writeUInt32BE(zlib.crc32(out.subarray(4, 8 + body.length)), 8 + body.length); + return out; + }; + const png = (width, height) => { + const header = Buffer.alloc(13); + header.writeUInt32BE(width); + header.writeUInt32BE(height, 4); + header.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header), chunk('IDAT', zlib.deflateSync(Buffer.alloc(64))), chunk('IEND', Buffer.alloc(0))]); + }; + // a 4000x4000 screen with 50 frames of a pixel + const frame = Buffer.from([0x21, 0xf9, 4, 0, 1, 0, 0, 0, 0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 1, 0]); + const gif = Buffer.concat([Buffer.from('GIF89a'), Buffer.from([0xa0, 0x0f, 0xa0, 0x0f, 0x80, 0, 0, 0, 0, 0, 255, 255, 255]), ...Array(50).fill(frame), Buffer.from([0x3b])]); + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0, 17, 8, 0xff, 0xdc, 0xff, 0xdc, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xff, 0xd9]); + // 278x374 with 57859 samples per pixel (a damaged TIFF that took 6 GB) + const entries = [[256, 4, 1, 278], [257, 4, 1, 374], [258, 3, 1, 8], [259, 3, 1, 1], [262, 3, 1, 2], [273, 4, 1, 8], [277, 3, 1, 57859], [278, 4, 1, 374], [279, 4, 1, 4]]; + const tiff = Buffer.alloc(8 + 2 + entries.length * 12 + 4); + tiff.write('II*\0', 0, 'latin1'); + tiff.writeUInt32LE(8, 4); + tiff.writeUInt16LE(entries.length, 8); + entries.forEach(([tag, type, count, value], k) => { + const o = 10 + k * 12; + tiff.writeUInt16LE(tag, o); + tiff.writeUInt16LE(type, o + 2); + tiff.writeUInt32LE(count, o + 4); + if (type === 3) tiff.writeUInt16LE(value, o + 8); + else tiff.writeUInt32LE(value, o + 8); + }); + + const tooLarge = async (name, decode) => { + const start = Date.now(); + let error = null; + try { await decode(); } catch (err) { error = err; } + assert(error instanceof RangeError && /too large/.test(error.message), `${name}: should be too large (${error && error.message})`); + assert(Date.now() - start < 1000, `${name}: took ${Date.now() - start} ms`); + }; + await tooLarge('png', () => Image.decode(png(30000, 30000))); + await tooLarge('png animation', () => Animation.decode(png(30000, 30000))); + await tooLarge('jpeg', () => Image.decode(jpeg)); + await tooLarge('tiff samples', () => Image.decode(tiff)); + await tooLarge('gif frames', () => Animation.decode(gif, {maxPixels: 4000 * 4000 * 10})); + await tooLarge('gif frames through GIF.decode', () => GIF.decode(gif, {maxPixels: 4000 * 4000 * 10})); + await tooLarge('maxPixels', () => Image.decode(png(100, 100), {maxPixels: 9999})); + + // without a limit, the PNG gets as far as its data (which is too short) + let error = null; + try { await Image.decode(png(30000, 30000), {maxPixels: Infinity}); } catch (err) { error = err; } + assert(error && /truncated/.test(error.message), `maxPixels: Infinity should lift the limit (${error && error.message})`); + // the first frame alone fits + assert((await Animation.decode(gif, {maxPixels: 4000 * 4000 * 10, onlyFirstFrame: true})).length === 1, 'the first frame should fit the limit'); + + // damaged data + error = null; + try { await Image.decode(Buffer.from([0x52, 0x49, 0x46, 0x46, 0x10, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c, 0xff, 0, 0, 0])); } catch (err) { error = err; } + assert(error && !(error instanceof RangeError), `damaged data should throw an Error, not a RangeError (${error && error.message})`); + } + + // blur with a huge radius takes about as long as with a small one, infinite ones throw + { + const image = new Image(200, 100).fill(x => x < 100 ? 0xff0000ff : 0x0000ffff); + const start = Date.now(); + image.blur(1e9); + assert(Date.now() - start < 1000, `blur(1e9) took ${Date.now() - start} ms`); + let threw = false; + try { image.blur(Infinity); } catch { threw = true; } + assert(threw, 'blur(Infinity) should throw'); + } })();