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'); + } })();