mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
104bb35919
commit
933be858c5
4 files changed
+181
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user