mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
214 lines
7.5 KiB
TypeScript
214 lines
7.5 KiB
TypeScript
/**
|
|
* 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) };
|
|
}
|
|
|
|
/**
|
|
* 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 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 frames > 0 ? { width, height } : null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|