fix(images): size a GIF by its largest frame, not only its logical screen

The header check before a thumbnail decode read only the GIF logical screen.
A decoder sizes its buffer from the frame's image descriptor, so a file with a
small screen and a huge frame passed the pixel cap and allocated the full
frame. Walk every block, read each image descriptor, size the image by the
largest of screen and frames, and refuse a file that ends inside a header or
descriptor, carries an unknown block, or has no frame.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 16:53:31 +01:00
1 parent 104bb35919
commit 933be858c5
4 files changed
+181 -7

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { Buffer } from "node:buffer";
const u16 = (n: number) => [n & 0xff, (n >> 8) & 0xff];
/**
* A GIF with the given logical screen and frames. Each frame carries one
* byte of image data, which is enough for a header walk and for a decoder.
*/
export function gif(
screen: { width: number; height: number; colourTable?: boolean },
frames: Array<{
width: number;
height: number;
left?: number;
top?: number;
extension?: boolean;
}>,
): Buffer {
const out: number[] = [...Buffer.from("GIF89a", "latin1")];
out.push(...u16(screen.width), ...u16(screen.height));
out.push(screen.colourTable ? 0x80 : 0x00, 0, 0);
if (screen.colourTable) out.push(0, 0, 0, 255, 255, 255);
for (const frame of frames) {
if (frame.extension) {
// A graphic control extension.
out.push(0x21, 0xf9, 4, 0, 0, 0, 0, 0);
}
out.push(0x2c, ...u16(frame.left ?? 0), ...u16(frame.top ?? 0));
out.push(...u16(frame.width), ...u16(frame.height), 0x80);
out.push(0, 0, 0, 255, 255, 255); // local colour table, two entries
out.push(2, 2, 0x44, 0x01, 0); // LZW size, one sub-block, terminator
}
out.push(0x3b);
return Buffer.from(out);
}
+53 -3
View File
@@ -4,6 +4,7 @@ import { createCanvas } from "@napi-rs/canvas";
import { describe, expect, it } from "vitest";
import { readImageDimensions } from "../image-dimensions";
import { gif } from "./gif-fixture";
/**
* The header walk both the avatar upload and the document thumbnail trust to
@@ -36,9 +37,58 @@ describe("readImageDimensions", () => {
);
});
it("reads a GIF's logical screen", () => {
const gif = Buffer.from("GIF89a\x25\x00\x17\x00\x00\x00\x00", "latin1");
expect(readImageDimensions(gif, "image/gif")).toEqual(SIZE);
it("reads a GIF's logical screen when its frames fit inside it", () => {
expect(
readImageDimensions(
gif({ width: 37, height: 23 }, [{ width: 37, height: 23 }]),
"image/gif",
),
).toEqual(SIZE);
});
it("sizes a GIF by its largest frame, not only its logical screen", () => {
// The decoder allocates for the frame: 10 x 10 on the screen, 20000 x
// 20000 in the descriptor was a 1.6 GB decode behind a 100-pixel check.
expect(
readImageDimensions(
gif({ width: 10, height: 10 }, [{ width: 20_000, height: 20_000 }]),
"image/gif",
),
).toEqual({ width: 20_000, height: 20_000 });
// A later frame, an offset, a global colour table and an extension in
// between are all walked.
expect(
readImageDimensions(
gif({ width: 10, height: 10, colourTable: true }, [
{ width: 4, height: 4 },
{ left: 60_000, top: 5, width: 5_000, height: 3, extension: true },
]),
"image/gif",
),
).toEqual({ width: 65_000, height: 10 });
});
it("refuses a GIF it cannot walk to the end of a frame descriptor", () => {
const whole = gif({ width: 10, height: 10 }, [{ width: 10, height: 10 }]);
// Logical screen only: no frame, no size.
expect(readImageDimensions(whole.subarray(0, 13), "image/gif")).toBeNull();
// Cut inside the image descriptor.
expect(readImageDimensions(whole.subarray(0, 18), "image/gif")).toBeNull();
// A block no decoder knows.
const unknown = Buffer.from(whole);
unknown[13] = 0x99;
expect(readImageDimensions(unknown, "image/gif")).toBeNull();
// A zero-sized frame.
expect(
readImageDimensions(
gif({ width: 10, height: 10 }, [{ width: 0, height: 10 }]),
"image/gif",
),
).toBeNull();
// Not a GIF signature a decoder accepts.
const signature = Buffer.from(whole);
signature.write("GIF80a", 0, "latin1");
expect(readImageDimensions(signature, "image/gif")).toBeNull();
});
it("skips fill bytes, RSTn and TEM before the frame header", () => {
@@ -19,6 +19,7 @@ vi.mock("@napi-rs/canvas", async (importActual) => {
};
});
import { gif } from "../../__tests__/gif-fixture";
import { generateThumbnail, THUMB_LONG_EDGE } from "../thumbnail";
/**
@@ -214,6 +215,32 @@ describe("generateThumbnail", () => {
expect(result.ok).toBe(true);
});
it("refuses a GIF whose frame is far larger than its logical screen, before decoding", async () => {
loadImageMock.mockClear();
// A 10 x 10 screen around a 20000 x 20000 frame: the decoder sizes its
// buffer from the frame.
const bomb = gif({ width: 10, height: 10 }, [
{ width: 20_000, height: 20_000 },
]);
expect(bomb.length).toBeLessThan(64);
await expect(generateThumbnail(bomb, "image/gif")).resolves.toEqual({
ok: false,
reason: "pixel-cap",
});
expect(loadImageMock).not.toHaveBeenCalled();
});
it("still previews an ordinary GIF", async () => {
const result = await generateThumbnail(
gif({ width: 1, height: 1 }, [{ width: 1, height: 1 }]),
"image/gif",
);
expect(result).toMatchObject({
ok: true,
thumbnail: { width: 1, height: 1 },
});
});
it("refuses an image whose size cannot be read instead of decoding it", async () => {
loadImageMock.mockClear();
// Start of image, then straight into a scan: no frame header to read.
+66 -4
View File
@@ -64,12 +64,74 @@ function readPng(bytes: Buffer): ImageDimensions | null {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
/**
* The area a GIF decoder has to allocate: the logical screen, grown to cover
* every frame's image descriptor.
*
* The logical screen alone is not that size. A decoder sizes its buffer from
* the frame it decodes, so a file declaring a 10 x 10 screen and a
* 20000 x 20000 frame passed a screen-only check and allocated 1.6 GB. Every
* block is walked (GIF89a, section 17 onwards): extensions and image data are
* skipped sub-block by sub-block, each image descriptor is read, and anything
* that is not a block a decoder accepts makes the file unreadable. The walk
* is linear in the file's length.
*
* A file that ends inside a frame's image data is still read up to there,
* since decoders draw what they got; one that ends inside a header or a
* descriptor, or has no frame at all, has no size.
*/
function readGif(bytes: Buffer): ImageDimensions | null {
// "GIF87a" / "GIF89a", then the logical screen: width, height (LE).
if (bytes.length < 10 || bytes.toString("ascii", 0, 4) !== "GIF8") {
return null;
// "GIF87a" / "GIF89a", then the logical screen descriptor (7 bytes).
if (bytes.length < 13) return null;
const signature = bytes.toString("ascii", 0, 6);
if (signature !== "GIF87a" && signature !== "GIF89a") return null;
let width = bytes.readUInt16LE(6);
let height = bytes.readUInt16LE(8);
const screenFlags = bytes[10]!;
let offset = 13;
if (screenFlags & 0x80) offset += 3 << ((screenFlags & 0x07) + 1);
/** Past a run of data sub-blocks; `bytes.length` when the file ends in it. */
const skipSubBlocks = (from: number): number => {
let at = from;
while (at < bytes.length) {
const size = bytes[at]!;
at += 1;
if (size === 0) return at;
at += size;
}
return bytes.length;
};
let frames = 0;
while (offset < bytes.length) {
const introducer = bytes[offset]!;
if (introducer === 0x3b) break; // trailer
if (introducer === 0x21) {
// Extension: introducer, label, then data sub-blocks.
if (offset + 2 > bytes.length) return null;
offset = skipSubBlocks(offset + 2);
continue;
}
if (introducer !== 0x2c) return null;
// Image descriptor: left, top, width, height (LE), flags.
if (offset + 10 > bytes.length) return null;
const left = bytes.readUInt16LE(offset + 1);
const top = bytes.readUInt16LE(offset + 3);
const frameWidth = bytes.readUInt16LE(offset + 5);
const frameHeight = bytes.readUInt16LE(offset + 7);
const frameFlags = bytes[offset + 9]!;
if (frameWidth === 0 || frameHeight === 0) return null;
width = Math.max(width, left + frameWidth);
height = Math.max(height, top + frameHeight);
frames += 1;
offset += 10;
if (frameFlags & 0x80) offset += 3 << ((frameFlags & 0x07) + 1);
// The LZW minimum code size, then the image data sub-blocks.
if (offset >= bytes.length) break;
offset = skipSubBlocks(offset + 1);
}
return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
return frames > 0 ? { width, height } : null;
}
/**