diff --git a/src/lib/__tests__/gif-fixture.ts b/src/lib/__tests__/gif-fixture.ts new file mode 100644 index 000000000..c166e5e7a --- /dev/null +++ b/src/lib/__tests__/gif-fixture.ts @@ -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); +} diff --git a/src/lib/__tests__/image-dimensions.test.ts b/src/lib/__tests__/image-dimensions.test.ts index 9145b1304..76cdc39ce 100644 --- a/src/lib/__tests__/image-dimensions.test.ts +++ b/src/lib/__tests__/image-dimensions.test.ts @@ -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", () => { diff --git a/src/lib/documents/__tests__/thumbnail.test.ts b/src/lib/documents/__tests__/thumbnail.test.ts index 68fc8cc6d..7620df07e 100644 --- a/src/lib/documents/__tests__/thumbnail.test.ts +++ b/src/lib/documents/__tests__/thumbnail.test.ts @@ -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. diff --git a/src/lib/image-dimensions.ts b/src/lib/image-dimensions.ts index 64346a025..806c5f6b1 100644 --- a/src/lib/image-dimensions.ts +++ b/src/lib/image-dimensions.ts @@ -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; } /**