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