mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
518ce2fd37
commit
ba22932101
6 files changed
+333
-230
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
+8
-130
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user