From ba22932101f31fc8291afb1aa4e20a79b9b364c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 12:25:44 +0100 Subject: [PATCH] fix(documents): read a JPEG's size past fill bytes and refuse an unreadable one The thumbnail's header walk took the byte after any 0xFF as a marker. A single 0xFF fill byte after the start-of-image marker, which the JPEG standard allows and every decoder skips, threw it off; it read no size, and an unread size let the image through to a decode that allocates the full bitmap. A small JPEG declaring 20000 x 20000 pixels allocated 1.6 GB in the web process before the post-decode check could see it. The avatar upload already had a walker that skips fill bytes. Both now use one reader (lib/image-dimensions) that follows the marker rules: fill bytes, the length-less markers, and no size when a scan or the end of the image comes before a frame header. The thumbnail treats an unreadable size as a refusal (size-unreadable) for every format it decodes, and the backfill counts that refusal as final. --- src/lib/__tests__/image-dimensions.test.ts | 78 +++++++++ src/lib/avatar.ts | 138 +--------------- src/lib/documents/__tests__/thumbnail.test.ts | 72 ++++++++- src/lib/documents/thumbnail.ts | 119 ++++---------- src/lib/image-dimensions.ts | 151 ++++++++++++++++++ src/lib/jobs/document-thumbnail-backfill.ts | 5 +- 6 files changed, 333 insertions(+), 230 deletions(-) create mode 100644 src/lib/__tests__/image-dimensions.test.ts create mode 100644 src/lib/image-dimensions.ts diff --git a/src/lib/__tests__/image-dimensions.test.ts b/src/lib/__tests__/image-dimensions.test.ts new file mode 100644 index 000000000..9145b1304 --- /dev/null +++ b/src/lib/__tests__/image-dimensions.test.ts @@ -0,0 +1,78 @@ +import { Buffer } from "node:buffer"; + +import { createCanvas } from "@napi-rs/canvas"; +import { describe, expect, it } from "vitest"; + +import { readImageDimensions } from "../image-dimensions"; + +/** + * The header walk both the avatar upload and the document thumbnail trust to + * keep an oversize image away from a decoder. `null` is a refusal at both + * call sites, so the walk must find the size wherever a decoder would. + */ + +function encoded(mime: "image/jpeg" | "image/png" | "image/webp"): Buffer { + const canvas = createCanvas(37, 23); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#808080"; + ctx.fillRect(0, 0, 37, 23); + return mime === "image/jpeg" + ? canvas.toBuffer(mime, 80) + : canvas.toBuffer(mime as "image/png"); +} + +const SIZE = { width: 37, height: 23 }; + +describe("readImageDimensions", () => { + it("reads PNG, JPEG and WebP as the encoder wrote them", () => { + expect(readImageDimensions(encoded("image/png"), "image/png")).toEqual( + SIZE, + ); + expect(readImageDimensions(encoded("image/jpeg"), "image/jpeg")).toEqual( + SIZE, + ); + expect(readImageDimensions(encoded("image/webp"), "image/webp")).toEqual( + SIZE, + ); + }); + + 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("skips fill bytes, RSTn and TEM before the frame header", () => { + const jpeg = encoded("image/jpeg"); + const withFill = Buffer.concat([ + jpeg.subarray(0, 2), + Buffer.from([0xff, 0xff, 0xff, 0x01, 0xff, 0xd3]), + jpeg.subarray(2), + ]); + expect(readImageDimensions(withFill, "image/jpeg")).toEqual(SIZE); + }); + + it("finds no size in a scan or an end of image before any frame header", () => { + expect( + readImageDimensions( + Buffer.from([0xff, 0xd8, 0xff, 0xda, 0, 8, 1, 2, 3, 4, 5, 6]), + "image/jpeg", + ), + ).toBeNull(); + expect( + readImageDimensions(Buffer.from([0xff, 0xd8, 0xff, 0xd9]), "image/jpeg"), + ).toBeNull(); + }); + + it("answers null for truncated and zero-sized headers, never throwing", () => { + const jpeg = encoded("image/jpeg"); + const sof = jpeg.indexOf(Buffer.from([0xff, 0xc0])); + expect(readImageDimensions(jpeg.subarray(0, sof + 4), "image/jpeg")).toBe( + null, + ); + const zero = Buffer.from(encoded("image/png")); + zero.writeUInt32BE(0, 16); + expect(readImageDimensions(zero, "image/png")).toBeNull(); + expect(readImageDimensions(Buffer.alloc(3), "image/webp")).toBeNull(); + expect(readImageDimensions(Buffer.alloc(0), "image/gif")).toBeNull(); + }); +}); diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts index e3b893980..f9732284f 100644 --- a/src/lib/avatar.ts +++ b/src/lib/avatar.ts @@ -8,8 +8,9 @@ * the format's header bytes by hand. Adding a heavyweight image * library (sharp / jimp) would pull a native dependency into the * Alpine standalone image just to read width + height — the hand- - * rolled parser is < 100 LOC and covers JPEG / PNG / WebP. + * rolled parser lives in `./image-dimensions` and covers JPEG / PNG / WebP. */ +import { readImageDimensions, type ImageDimensions } from "./image-dimensions"; /** Hard upload cap. 2 MiB is comfortable for a 512×512 JPEG. */ export const AVATAR_MAX_BYTES = 2 * 1024 * 1024; @@ -64,142 +65,19 @@ export function detectAvatarMimeType(buffer: Buffer): AvatarMime | null { return null; } -export interface AvatarDimensions { - width: number; - height: number; -} +export type AvatarDimensions = ImageDimensions; /** - * Read the native image dimensions from the header bytes. Each - * format encodes width + height at a fixed offset in the leading - * chunks, so a full decode is unnecessary: - * - * - PNG: IHDR chunk after the 8-byte signature; width at offset - * 16, height at offset 20, both big-endian 32-bit unsigned. - * - JPEG: walk the marker segments until the first SOFn (Start - * Of Frame) marker, then read the two big-endian 16-bit values - * at marker offset + 5 (height) and + 7 (width). - * - WebP: three sub-formats (VP8 / VP8L / VP8X); each pins - * width-1 and height-1 at known offsets in the leading chunk. - * - * Returns `null` when the buffer is truncated or the format header - * is malformed; the caller maps that to a 422. + * Read the native image dimensions from the header bytes, without a + * decode (`readImageDimensions`, shared with the document thumbnail). + * Returns `null` when the buffer is truncated or the format header is + * malformed; the caller maps that to a 422. */ export function readAvatarDimensions( buffer: Buffer, mime: AvatarMime, ): AvatarDimensions | null { - switch (mime) { - case "image/png": - return readPngDimensions(buffer); - case "image/jpeg": - return readJpegDimensions(buffer); - case "image/webp": - return readWebpDimensions(buffer); - } -} - -function readPngDimensions(buffer: Buffer): AvatarDimensions | null { - // 8-byte signature + 4-byte chunk length + "IHDR" + 4-byte width - // + 4-byte height = first valid read at offset 16. - if (buffer.length < 24) return null; - if (buffer.toString("ascii", 12, 16) !== "IHDR") return null; - const width = buffer.readUInt32BE(16); - const height = buffer.readUInt32BE(20); - if (width <= 0 || height <= 0) return null; - return { width, height }; -} - -function readJpegDimensions(buffer: Buffer): AvatarDimensions | null { - // SOI marker (FF D8) confirmed by `detectAvatarMimeType`. Walk - // the marker segments until the first SOFn (Start Of Frame) - // marker (FF C0 .. FF C3, FF C5 .. FF C7, FF C9 .. FF CB, FF CD .. - // FF CF). Every other marker carries a 16-bit length we can skip. - let offset = 2; - while (offset + 9 < buffer.length) { - if (buffer[offset] !== 0xff) return null; - // Skip any fill bytes (FF FF ...) - while (offset < buffer.length && buffer[offset] === 0xff) { - offset += 1; - } - if (offset >= buffer.length) return null; - const marker = buffer[offset]; - offset += 1; - - // Standalone markers without a payload. - if (marker === 0xd8 || marker === 0xd9) continue; - if (marker >= 0xd0 && marker <= 0xd7) continue; - - if (offset + 7 >= buffer.length) return null; - const segmentLength = buffer.readUInt16BE(offset); - if (segmentLength < 2) return null; - - const isSof = - (marker >= 0xc0 && marker <= 0xc3) || - (marker >= 0xc5 && marker <= 0xc7) || - (marker >= 0xc9 && marker <= 0xcb) || - (marker >= 0xcd && marker <= 0xcf); - - if (isSof) { - // The SOF payload layout is: - // length (2) | precision (1) | height (2) | width (2) | ... - const height = buffer.readUInt16BE(offset + 3); - const width = buffer.readUInt16BE(offset + 5); - if (width <= 0 || height <= 0) return null; - return { width, height }; - } - - offset += segmentLength; - } - return null; -} - -function readWebpDimensions(buffer: Buffer): AvatarDimensions | null { - // RIFF / WEBP confirmed by `detectAvatarMimeType`. The chunk at - // offset 12 is one of: - // "VP8 " — lossy - // "VP8L" — lossless - // "VP8X" — extended - if (buffer.length < 30) return null; - const fourCc = buffer.toString("ascii", 12, 16); - - if (fourCc === "VP8 ") { - // VP8 frame header. Width/height live at chunk-payload offset - // 6 / 8 as 14-bit values (bottom 14 bits of a 16-bit field). - if (buffer.length < 30) return null; - const width = buffer.readUInt16LE(26) & 0x3fff; - const height = buffer.readUInt16LE(28) & 0x3fff; - if (width <= 0 || height <= 0) return null; - return { width, height }; - } - - if (fourCc === "VP8L") { - // VP8L header: 1-byte signature (0x2f) + 4-byte packed dims. - // width-1 = 14 low bits of the 32-bit little-endian word; - // height-1 = the next 14 bits. - if (buffer.length < 25) return null; - if (buffer[20] !== 0x2f) return null; - const packed = buffer.readUInt32LE(21); - const width = (packed & 0x3fff) + 1; - const height = ((packed >> 14) & 0x3fff) + 1; - if (width <= 0 || height <= 0) return null; - return { width, height }; - } - - if (fourCc === "VP8X") { - // VP8X chunk: 1-byte flags + 3-byte reserved + 3-byte canvas - // width-1 + 3-byte canvas height-1 (all little-endian, padded - // to 24 bits). - if (buffer.length < 30) return null; - const widthMinusOne = buffer[24] | (buffer[25] << 8) | (buffer[26] << 16); - const heightMinusOne = buffer[27] | (buffer[28] << 8) | (buffer[29] << 16); - const width = widthMinusOne + 1; - const height = heightMinusOne + 1; - if (width <= 0 || height <= 0) return null; - return { width, height }; - } - - return null; + return readImageDimensions(buffer, mime); } /** diff --git a/src/lib/documents/__tests__/thumbnail.test.ts b/src/lib/documents/__tests__/thumbnail.test.ts index 05a2630d6..68fc8cc6d 100644 --- a/src/lib/documents/__tests__/thumbnail.test.ts +++ b/src/lib/documents/__tests__/thumbnail.test.ts @@ -132,7 +132,15 @@ describe("generateThumbnail", () => { it("returns { ok: false } and never throws on malformed image bytes", async () => { await expect( generateThumbnail(Buffer.from("not a real image"), "image/png"), - ).resolves.toEqual({ ok: false, reason: "error" }); + ).resolves.toEqual({ ok: false, reason: "size-unreadable" }); + }); + + it("returns { ok: false } when a readable header fronts bytes that do not decode", async () => { + const header = sourceImage(8, 8, "image/png").subarray(0, 40); + await expect(generateThumbnail(header, "image/png")).resolves.toEqual({ + ok: false, + reason: "error", + }); }); it("returns { ok: false } on a malformed PDF", async () => { @@ -158,13 +166,65 @@ describe("generateThumbnail", () => { }); it("refuses an oversize image the header sniff missed (post-decode cap)", async () => { - // Bytes the JPEG sniffer cannot read (no SOI) → the pre-decode guard is - // skipped and the code falls through to `loadImage`. The decoder is forced - // to return 30000×30000 = 900 MP, so only the post-decode backstop can - // catch it. Without that backstop this would OOM the serial worker. + // A readable header declaring a small image whose decoder is forced to + // return 30000×30000 = 900 MP: only the post-decode backstop can catch + // it. loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 }); await expect( - generateThumbnail(Buffer.from("desynced jpeg bytes"), "image/jpeg"), + generateThumbnail(sourceImage(16, 16, "image/jpeg"), "image/jpeg"), ).resolves.toEqual({ ok: false, reason: "pixel-cap" }); }); + + /** + * A JPEG whose frame header declares `width` × `height`, with `fill` 0xFF + * fill bytes after the start-of-image marker. The standard allows fill + * bytes before any marker and every decoder skips them. + */ + function jpegDeclaring(width: number, height: number, fill: number): Buffer { + const base = sourceImage(16, 16, "image/jpeg"); + const sof = base.indexOf(Buffer.from([0xff, 0xc0])); + const patched = Buffer.from(base); + patched.writeUInt16BE(height, sof + 5); + patched.writeUInt16BE(width, sof + 7); + return Buffer.concat([ + patched.subarray(0, 2), + Buffer.alloc(fill, 0xff), + patched.subarray(2), + ]); + } + + it("reads the size of a JPEG with fill bytes before a marker, and refuses a bomb before decoding", async () => { + loadImageMock.mockClear(); + // 20000×20000 behind one fill byte: the old walker lost its place, read + // no size and let the decoder allocate 1.6 GB. + await expect( + generateThumbnail(jpegDeclaring(20_000, 20_000, 1), "image/jpeg"), + ).resolves.toEqual({ ok: false, reason: "pixel-cap" }); + await expect( + generateThumbnail(jpegDeclaring(20_000, 20_000, 3), "image/jpeg"), + ).resolves.toEqual({ ok: false, reason: "pixel-cap" }); + expect(loadImageMock).not.toHaveBeenCalled(); + }); + + it("still previews an ordinary JPEG that carries fill bytes", async () => { + const result = await generateThumbnail( + jpegDeclaring(16, 16, 2), + "image/jpeg", + ); + expect(result.ok).toBe(true); + }); + + 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. + const noFrame = Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 1, 2, 3]); + await expect(generateThumbnail(noFrame, "image/jpeg")).resolves.toEqual({ + ok: false, + reason: "size-unreadable", + }); + await expect( + generateThumbnail(Buffer.from("RIFF0000WEBPVP8Z"), "image/webp"), + ).resolves.toEqual({ ok: false, reason: "size-unreadable" }); + expect(loadImageMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/documents/thumbnail.ts b/src/lib/documents/thumbnail.ts index 517011dc7..7cc504e42 100644 --- a/src/lib/documents/thumbnail.ts +++ b/src/lib/documents/thumbnail.ts @@ -31,6 +31,10 @@ import { Buffer } from "node:buffer"; import { nativeCanvasSupported } from "./native-canvas-support"; import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; +import { + readImageDimensions, + type DimensionedImageMime, +} from "@/lib/image-dimensions"; import { annotate } from "@/lib/logging/context"; /** Longest-edge pixel cap for the preview — a legible card tile at ~10-25 KB. */ @@ -60,94 +64,16 @@ const THUMBNAILABLE_IMAGE_MIMES = new Set([ ]); /** - * Read a source image's pixel count (width × height) from its container header - * WITHOUT decoding the bitmap — the guard that keeps a decompression bomb out of - * `loadImage`. Covers the four thumbnailable formats. Returns null when the - * header can't be read (a malformed file `loadImage` will itself reject, or a - * format we don't pre-screen); a bomb necessarily carries a parseable header - * declaring huge dimensions, so a null here is never the bomb case. + * A source image's pixel count (width × height) from its container header, + * WITHOUT decoding the bitmap — the guard that keeps a decompression bomb out + * of `loadImage`. `null` when the header cannot be read, which the caller + * treats as a refusal: the decoder reads headers more leniently than any + * hand-written walker, so an unreadable size is no evidence of a small one. */ function sniffSourcePixels(bytes: Buffer, mimeType: string): number | null { - try { - switch (mimeType) { - case "image/png": { - // 8-byte signature + 4-byte length + "IHDR" → width@16, height@20 (BE). - if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) - return null; - return bytes.readUInt32BE(16) * bytes.readUInt32BE(20); - } - case "image/gif": { - // "GIF8" + logical-screen descriptor: width@6, height@8 (uint16 LE). - if (bytes.length < 10) return null; - return bytes.readUInt16LE(6) * bytes.readUInt16LE(8); - } - case "image/jpeg": - return sniffJpegPixels(bytes); - case "image/webp": - return sniffWebpPixels(bytes); - default: - return null; - } - } catch { - return null; - } -} - -/** Walk JPEG marker segments to the first Start-Of-Frame and read its W×H. */ -function sniffJpegPixels(bytes: Buffer): number | null { - if (bytes.length < 4 || bytes.readUInt16BE(0) !== 0xffd8) return null; // SOI - let offset = 2; - while (offset + 9 < bytes.length) { - if (bytes[offset] !== 0xff) return null; - const marker = bytes[offset + 1]!; - const segLen = bytes.readUInt16BE(offset + 2); - if (segLen < 2) return null; - // SOF0-15 carry dimensions, EXCEPT DHT (C4), JPG (C8), DAC (CC). - const isSof = - marker >= 0xc0 && - marker <= 0xcf && - marker !== 0xc4 && - marker !== 0xc8 && - marker !== 0xcc; - if (isSof) { - const height = bytes.readUInt16BE(offset + 5); - const width = bytes.readUInt16BE(offset + 7); - return width * height; - } - offset += 2 + segLen; - } - return null; -} - -/** Read W×H from a WebP RIFF header (VP8 lossy / VP8L lossless / VP8X ext). */ -function sniffWebpPixels(bytes: Buffer): number | null { - if (bytes.length < 30) return null; - if (bytes.readUInt32BE(0) !== 0x52494646) return null; // "RIFF" - if (bytes.readUInt32BE(8) !== 0x57454250) return null; // "WEBP" - const fourcc = bytes.toString("ascii", 12, 16); - if (fourcc === "VP8 ") { - // Lossy: 14-bit width/height (LE) at 26/28. - const w = bytes.readUInt16LE(26) & 0x3fff; - const h = bytes.readUInt16LE(28) & 0x3fff; - return w * h; - } - if (fourcc === "VP8L") { - // Lossless: 14-bit each, bit-packed after the 1-byte signature at 21. - const b0 = bytes[21]!; - const b1 = bytes[22]!; - const b2 = bytes[23]!; - const b3 = bytes[24]!; - const w = 1 + (((b1 & 0x3f) << 8) | b0); - const h = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)); - return w * h; - } - if (fourcc === "VP8X") { - // Extended: 24-bit LE (stored value + 1) width@24, height@27. - const w = 1 + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)); - const h = 1 + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)); - return w * h; - } - return null; + if (!THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) return null; + const size = readImageDimensions(bytes, mimeType as DimensionedImageMime); + return size ? size.width * size.height : null; } /** The generated preview plus its decoded dimensions (for `Content-Length`). */ @@ -166,6 +92,7 @@ export type ThumbnailSkipReason = | "unsupported-type" | "native-canvas-unsupported" | "pixel-cap" + | "size-unreadable" | "raster-failed" | "resource-limit" | "empty-render" @@ -252,9 +179,18 @@ export async function generateThumbnail( (await import("@napi-rs/canvas")) as unknown as NapiCanvasModule; if (THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) { - // Refuse a decompression bomb BEFORE `loadImage` allocates the bitmap. + // Refuse a decompression bomb BEFORE `loadImage` allocates the bitmap, + // and refuse an image whose size cannot be read: that one would reach + // the decoder with no ceiling at all. const pixels = sniffSourcePixels(bytes, mimeType); - if (pixels !== null && pixels > MAX_SOURCE_PIXELS) { + if (pixels === null) { + annotate({ + action: { name: "documents.thumbnail.rejected" }, + meta: { source: "image", reason: "size_unreadable" }, + }); + return { ok: false, reason: "size-unreadable" }; + } + if (pixels > MAX_SOURCE_PIXELS) { annotate({ action: { name: "documents.thumbnail.rejected" }, meta: { source: "image", reason: "pixel_cap", pixels }, @@ -262,10 +198,9 @@ export async function generateThumbnail( return { ok: false, reason: "pixel-cap" }; } const image = await napi.loadImage(bytes); - // Post-decode backstop: the header sniff returns null on any marker - // desync (a malformed-but-decodable JPEG), falling through to here with - // NO ceiling. Re-check the DECODED dimensions so such a source can't - // bypass the cap and OOM the serial worker. + // Post-decode backstop: should a decoder ever read a larger frame than + // the header walk did, the decoded size is checked again before the + // bitmap is drawn. if (image.width * image.height > MAX_SOURCE_PIXELS) { annotate({ action: { name: "documents.thumbnail.rejected" }, diff --git a/src/lib/image-dimensions.ts b/src/lib/image-dimensions.ts new file mode 100644 index 000000000..64346a025 --- /dev/null +++ b/src/lib/image-dimensions.ts @@ -0,0 +1,151 @@ +/** + * An image's declared width and height, read from its container header + * without decoding it. + * + * Two places depend on this. The avatar upload refuses anything larger than + * it serves, and the document thumbnail refuses to decode a source past its + * pixel cap, because the decoder allocates the full bitmap before anything + * can look at it. Both treat `null` as a refusal: a decoder reads headers + * more leniently than a hand-written walker, so "could not read the size" is + * no evidence the size is small. + * + * There used to be two walkers. The thumbnail's took the byte after any 0xFF + * as a marker, so a single fill byte after the start-of-image marker, which + * the JPEG standard allows and every decoder skips, threw it off; it answered + * `null`, and `null` let the image through to a decode of 20000 x 20000 + * pixels. This is the one walker now. + */ + +export interface ImageDimensions { + width: number; + height: number; +} + +export type DimensionedImageMime = + "image/jpeg" | "image/png" | "image/webp" | "image/gif"; + +/** + * The declared size, or `null` when the header is truncated, malformed, or + * declares a zero side. Never throws. + */ +export function readImageDimensions( + bytes: Buffer, + mime: DimensionedImageMime, +): ImageDimensions | null { + try { + const size = readBySniffedType(bytes, mime); + if (!size || size.width <= 0 || size.height <= 0) return null; + return size; + } catch { + return null; + } +} + +function readBySniffedType( + bytes: Buffer, + mime: DimensionedImageMime, +): ImageDimensions | null { + switch (mime) { + case "image/png": + return readPng(bytes); + case "image/jpeg": + return readJpeg(bytes); + case "image/webp": + return readWebp(bytes); + case "image/gif": + return readGif(bytes); + } +} + +function readPng(bytes: Buffer): ImageDimensions | null { + // 8-byte signature + 4-byte chunk length + "IHDR" + width + height. + if (bytes.length < 24) return null; + if (bytes.toString("ascii", 12, 16) !== "IHDR") return null; + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; +} + +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; + } + return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) }; +} + +/** + * Walk the marker segments to the first Start Of Frame (ITU-T T.81, B.1.1). + * A marker is 0xFF followed by a byte that is neither 0x00 nor 0xFF; any + * number of 0xFF fill bytes may come before it. SOI, EOI, RST0-7 and TEM + * carry no length. A scan or the end of the image before any frame header + * means there is no size to read. + */ +function readJpeg(bytes: Buffer): ImageDimensions | null { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + let offset = 2; + while (offset < bytes.length) { + if (bytes[offset] !== 0xff) return null; + while (offset < bytes.length && bytes[offset] === 0xff) offset++; + if (offset >= bytes.length) return null; + const marker = bytes[offset]!; + offset++; + + if (marker === 0x00) return null; // a stuffed byte outside entropy data + if (marker === 0xd8 || marker === 0x01) continue; // SOI, TEM + if (marker >= 0xd0 && marker <= 0xd7) continue; // RST0-7 + if (marker === 0xd9 || marker === 0xda) return null; // EOI, SOS + + if (offset + 2 > bytes.length) return null; + const length = bytes.readUInt16BE(offset); + if (length < 2) return null; + + // SOF0-15 carry the frame size, except DHT (C4), JPG (C8) and DAC (CC). + const isSof = + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc; + if (isSof) { + // length (2) | precision (1) | height (2) | width (2) + if (offset + 7 > bytes.length) return null; + return { + height: bytes.readUInt16BE(offset + 3), + width: bytes.readUInt16BE(offset + 5), + }; + } + offset += length; + } + return null; +} + +function readWebp(bytes: Buffer): ImageDimensions | null { + if (bytes.length < 30) return null; + if (bytes.toString("ascii", 0, 4) !== "RIFF") return null; + if (bytes.toString("ascii", 8, 12) !== "WEBP") return null; + const fourCc = bytes.toString("ascii", 12, 16); + + if (fourCc === "VP8 ") { + // Lossy: 14-bit width and height (LE) in the frame header. + return { + width: bytes.readUInt16LE(26) & 0x3fff, + height: bytes.readUInt16LE(28) & 0x3fff, + }; + } + if (fourCc === "VP8L") { + // Lossless: signature 0x2f, then width-1 and height-1 in 14 bits each. + if (bytes[20] !== 0x2f) return null; + const packed = bytes.readUInt32LE(21); + return { + width: (packed & 0x3fff) + 1, + height: ((packed >>> 14) & 0x3fff) + 1, + }; + } + if (fourCc === "VP8X") { + // Extended: the canvas, width-1 and height-1 in 24 bits each (LE). + return { + width: 1 + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)), + height: 1 + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)), + }; + } + return null; +} diff --git a/src/lib/jobs/document-thumbnail-backfill.ts b/src/lib/jobs/document-thumbnail-backfill.ts index e921cb7ce..84b6107f3 100644 --- a/src/lib/jobs/document-thumbnail-backfill.ts +++ b/src/lib/jobs/document-thumbnail-backfill.ts @@ -49,8 +49,8 @@ const THUMBNAILABLE_MIMES = [ /** * Render refusals that the same bytes will always get again: the first page - * renders blank, the render comes out empty, the image is past the pixel cap, - * the PDF crossed the renderer's memory or time bound. One attempt settles + * renders blank, the render comes out empty, the image is past the pixel cap + * or its size cannot be read, the PDF crossed the renderer's memory or time bound. One attempt settles * them; queueing a document that exhausts the renderer again at every boot * would only repeat the exhaustion. */ @@ -58,6 +58,7 @@ const FINAL_THUMBNAIL_FAILURES = [ "raster-failed", "empty-render", "pixel-cap", + "size-unreadable", "resource-limit", ] as const;