Validating Image Dimensions and Pixel Bombs Server-Side
Read width, height and frame count from the container header with sharp().metadata() — which parses bytes without allocating a pixel buffer — reject anything over your pixel budget before a decoder ever runs, and set limitInputPixels on the decode call as a second, independent gate.
A decompression bomb is not a big file. It is a small file that becomes a big allocation. The canonical example is a 4 KB PNG declaring a 64000 × 64000 canvas of a single colour: 4.1 gigapixels, which libvips will happily try to materialise as 12.3 GB of RGB. Your Content-Length check passed, your MIME sniffing passed, your virus scanner found nothing, and the container dies with exit code 137 before your handler returns. This page sits inside server-side file validation and the wider backend validation and cloud storage architecture; it assumes the format has already been confirmed from magic bytes rather than from the upload’s declared type.
When to use this approach
- You accept images from anyone unauthenticated, or from authenticated users you would not hand a shell to — avatars, listing photos, support-ticket attachments, anything that reaches a resize worker.
- You run image processing in a memory-capped environment: a Lambda with 1024 MB, a Fargate task with a hard
memoryreservation, a Kubernetes pod with alimits.memory. Anywhere the OOM killer, not an exception handler, decides what happens next. - You already sniff the real format from magic bytes, as covered in validating file signatures with libmagic in Node.js, and now need the second question: not “is this a PNG” but “how expensive is this PNG”.
If your images only ever come from a trusted internal encoder and the sizes are known, a byte-size limit is enough and this is over-engineering. Everything below assumes the opposite.
Prerequisites
- Node.js 20+ with
sharp0.33 or 0.34 (npm i sharp). The examples use thepageHeight,pagesandfailOnfields, all present since 0.32. - A libvips build with PNG, WebP and GIF support. Check with
node -e 'import("sharp").then(s => console.log(s.default.format))'and confirmgif.input.bufferistrue. - A hard memory ceiling on the process that actually decodes — a cgroup limit, a Lambda
MemorySize, orsystemdMemoryMax. Node’s--max-old-space-sizedoes not apply here; libvips allocates outside the V8 heap. - The upload already written somewhere you can read in full or by range — a quarantine prefix works well, see quarantine bucket patterns for infected uploads.
The two lanes
There is exactly one boundary that matters: the point at which a decoder allocates width × height × channels bytes. Everything you can learn before that point is nearly free — a PNG’s IHDR chunk is at byte 16, a JPEG’s SOFn marker within the first few kilobytes, a WebP’s VP8X at byte 12. Everything after it is priced in gigabytes.
The redundancy is the point. metadata() in sharp 0.33 and 0.34 does not apply limitInputPixels — the check lives in the processing pipeline, not the header reader. That is convenient, because it means you can read 4.1 gigapixels off a bomb and log the exact number in your rejection instead of catching an opaque throw. It also means a code path that calls metadata() and then decodes without setting limitInputPixels is unprotected, which is why the option appears again on the second call.
Implementation
import sharp from "sharp";
// libvips allocates outside the V8 heap, so --max-old-space-size does nothing
// here. Cap the operation cache and serialise decodes instead: sharp's default
// concurrency is one thread per physical core, and four simultaneous 50 MP
// decodes is 600 MB of resident memory on their own.
sharp.cache({ memory: 64, files: 0, items: 100 });
sharp.concurrency(1);
const MAX_BYTES = 25 * 1024 * 1024; // refuse before touching the decoder
const MAX_FRAME_PIXELS = 50_000_000; // one frame: ~8660 × 5773
const MAX_SIDE = 20_000; // stops 200000 × 12 strips
const MAX_FRAMES = 300;
const MAX_TOTAL_PIXELS = 120_000_000; // frames × frame pixels
const ALLOWED_FORMATS = new Set(["jpeg", "png", "webp", "gif", "avif", "tiff"]);
export class ImageRejected extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = "ImageRejected";
}
}
export interface ImageFacts {
format: string;
width: number;
frameHeight: number;
frames: number;
totalPixels: number;
estimatedDecodeBytes: number;
}
export async function inspectImage(buf: Buffer): Promise<ImageFacts> {
if (buf.byteLength > MAX_BYTES) {
throw new ImageRejected("too_many_bytes", `${buf.byteLength} bytes over the ${MAX_BYTES} cap`);
}
let meta: sharp.Metadata;
try {
// metadata() parses the container header. libvips does not allocate a pixel
// buffer here, so this is the only call that is safe on untrusted bytes.
meta = await sharp(buf, { unlimited: false, failOn: "error" }).metadata();
} catch (err) {
throw new ImageRejected("undecodable", `header parse failed: ${(err as Error).message}`);
}
const format = meta.format ?? "unknown";
if (format === "svg") {
throw new ImageRejected("svg_rejected", "SVG is XML, not pixels — use the rasterise path");
}
if (!ALLOWED_FORMATS.has(format)) {
throw new ImageRejected("format_not_allowed", `format ${format} is not on the allow list`);
}
const width = meta.width ?? 0;
const frames = meta.pages ?? 1;
// pageHeight is ONE frame. meta.height becomes the full vertical strip when a
// file is opened with { animated: true }, so never budget against it.
const frameHeight = meta.pageHeight ?? meta.height ?? 0;
if (width <= 0 || frameHeight <= 0) {
throw new ImageRejected("no_dimensions", "header carried no usable width or height");
}
if (width > MAX_SIDE || frameHeight > MAX_SIDE) {
throw new ImageRejected("side_too_long", `${width}×${frameHeight} exceeds ${MAX_SIDE} px per side`);
}
const framePixels = width * frameHeight;
if (framePixels > MAX_FRAME_PIXELS) {
throw new ImageRejected("frame_too_large", `${framePixels} px per frame over ${MAX_FRAME_PIXELS}`);
}
if (frames > MAX_FRAMES) {
throw new ImageRejected("too_many_frames", `${frames} frames over ${MAX_FRAMES}`);
}
const totalPixels = framePixels * frames;
if (totalPixels > MAX_TOTAL_PIXELS) {
throw new ImageRejected("animation_too_large", `${totalPixels} total px over ${MAX_TOTAL_PIXELS}`);
}
const channels = meta.channels ?? 4;
return {
format,
width,
frameHeight,
frames,
totalPixels,
estimatedDecodeBytes: totalPixels * channels,
};
}
export async function makeThumbnail(buf: Buffer, edge = 1024): Promise<Buffer> {
const facts = await inspectImage(buf);
const animated = facts.frames > 1;
return sharp(buf, {
animated,
sequentialRead: true,
failOn: "error",
unlimited: false,
// With animated:true libvips reports height = frameHeight × frames and
// checks the limit against THAT, so the ceiling must be the strip's size.
limitInputPixels: animated ? MAX_TOTAL_PIXELS : MAX_FRAME_PIXELS,
})
.resize({ width: edge, height: edge, fit: "inside", withoutEnlargement: true })
.webp({ quality: 80, effort: 4 })
.toBuffer();
}
Line by line, on the parameters that matter
limitInputPixelsdefaults to268402689— that is0x3FFF × 0x3FFF, or 16383 × 16383. Generous: a 268 MP RGBA decode is 1.07 GB, which will kill a 1 GB Lambda. Passingfalseor0disables the check entirely; never do that on an untrusted path. The 50 MP used here is roughly a 60-megapixel medium-format scan, comfortably above anything a phone produces.unlimited: falseis the default and must stay that way. Setting it totruedoes not merely raise the pixel ceiling — for SVG input libvips passesRSVG_HANDLE_FLAG_UNLIMITEDto librsvg, which also switches off the XML parser’s entity-expansion limits. One flag, two very different safeties.failOn: "error"rejects genuinely corrupt input but tolerates warnings such as a truncated JPEG tail or a bad ICC profile. The sharp default is"warning", which rejects images every browser renders fine;"none"accepts a half-uploaded object as valid, which is worse.sequentialRead: truelets libvips stream a JPEG or TIFF top-to-bottom instead of holding the whole decoded raster. It is a real memory win for large photos and a no-op for PNG, where libspng needs the full image regardless — do not treat it as a substitute for the pixel budget.meta.pagesis the frame count for GIF and animated WebP and the page count for multi-page TIFF and PDF. It isundefined, not1, for a plain JPEG, hence the?? 1.meta.channelsis 3 for RGB and 4 for RGBA, but libvips works in a higher-precision format for 16-bit PNG and TIFF, soestimatedDecodeBytesis a floor, not a ceiling. Log it; do not build a scheduler on it.edge = 1024withfit: "inside"andwithoutEnlargement: truecaps the output too. A validated 50 MP input that resizes to 4000 × 4000 is still a 64 MB WebP encode buffer.
Persist the numbers you just paid for. Writing width, height and frame count into your catalogue at this point costs nothing and saves a re-probe later — the schema for that is in storing image dimensions and duration metadata.
Animated GIF and WebP multiply everything
A 500 × 500 GIF is 250,000 pixels. A 500 × 500 GIF with 1,200 frames is 300 million, and libvips represents it as one image 500 wide by 600,000 tall. That single file exceeds the default limitInputPixels on its own, in about 90 KB of upload, and it is trivial to build with gifsicle.
The trap is that the frame count is invisible unless you look for it. sharp(buf).metadata() on that file reports width: 500, height: 500, pages: 1200, pageHeight: 500 — the frame geometry, because without animated: true libvips only opens page zero. Budget on pages × width × pageHeight, never on width × height, and re-read the section above on why limitInputPixels has to be raised to the strip size once you actually open the animation.
Two further limits are worth adding for anything that will be re-encoded: a cap on total frames (300 is roughly ten seconds at 30 fps and covers real user content) and a cap on estimatedDecodeBytes expressed as a fraction of the worker’s memory limit. If a job would need more than about a third of the container, queue it to a larger worker rather than gambling — the same reasoning that drives worker sizing for 500 MB uploads.
SVG is a different hazard entirely
An SVG has no pixels. It has a declared width and height, a viewBox, and an XML document that a rasteriser must parse before it knows anything. A dimension check cannot protect you here, because the attack does not need large dimensions.
The default answer is to reject SVG. Most products that accept “images” do not need vector uploads, and the blast radius is not just memory: an SVG served from your own origin with Content-Type: image/svg+xml executes any <script> it contains in that origin, which is stored cross-site scripting with an image file extension. Browsers cannot help you, and neither can magic-byte detection, because a valid SVG genuinely is an image.
If you must accept it, rasterise on ingest and serve only the raster:
const XML_HAZARDS = /<!DOCTYPE|<!ENTITY|<\?xml-stylesheet|<script|<foreignObject/i;
const EXTERNAL_REF = /\b(?:xlink:)?href\s*=\s*["'](?!#|data:image\/(?:png|jpeg|gif|webp);)/i;
export async function rasteriseSvg(bytes: Buffer): Promise<Buffer> {
if (bytes.byteLength > 512 * 1024) {
throw new ImageRejected("svg_too_large", "SVG source over 512 KB");
}
// A coarse pre-filter, not a parser. It exists so the obvious cases never
// reach librsvg at all; the real controls are `unlimited: false`, the pixel
// limit, and the fact that we never serve these bytes back to a browser.
const head = bytes.subarray(0, 64 * 1024).toString("utf8");
if (XML_HAZARDS.test(head)) {
throw new ImageRejected("svg_hazard", "DOCTYPE, entity, script or foreignObject present");
}
if (EXTERNAL_REF.test(head)) {
throw new ImageRejected("svg_external_ref", "SVG references an external resource");
}
return sharp(bytes, {
density: 96, // default is 72; doubling this quadruples pixels
unlimited: false, // keeps librsvg's own XML limits switched on
limitInputPixels: 16_000_000,
failOn: "error",
})
.resize({ width: 1024, height: 1024, fit: "inside", withoutEnlargement: true })
.png({ compressionLevel: 9 })
.toBuffer();
}
Run that in a child process or a separate queue consumer with its own memory limit. A regex over XML is a filter, not a guarantee — the guarantee comes from the process boundary and from never handing the original bytes to a browser. If the original must be retained for the user, store it under a prefix that is only ever served with Content-Disposition: attachment and Content-Security-Policy: default-src 'none', or from a domain that shares no cookies with your app. That separation is easier to hold when uploads land in their own bucket, one of the arguments in presigned URL vs server proxy trade-offs.
Configuration gotchas
Error: Input image exceeds pixel limit — libvips threw this from the pipeline, meaning width × height of the opened image was above limitInputPixels. The message carries no numbers, which is why the header-lane check exists: it rejects with the actual dimensions so your logs are useful. If you see it on an image you expect to accept, remember the default is 16383 per side squared, not per side — a 30000 × 8000 panorama is 240 MP and passes, a 20000 × 20000 scan is 400 MP and does not.
Error: Input image exceeds pixel limit on a small animation — same string, different cause. You opened a GIF or WebP with { animated: true }, libvips built the frames as one tall strip, and height became pageHeight × pages. A 500 × 500 GIF with 1,200 frames is 500 × 600000. Raise limitInputPixels to your total-pixel budget on animated reads, as the implementation does, and keep the per-frame budget separate.
The process dies with no stack trace, exit code 137, Killed in dmesg — libvips allocated past the cgroup limit and the kernel OOM killer took the whole process. This is not catchable: process.on("uncaughtException") never fires, in-flight requests are dropped, and --max-old-space-size is irrelevant because none of that memory was on the V8 heap. The fixes are sharp.concurrency(1), sharp.cache({ memory: 64 }), decoding in a child process you can lose cheaply, and a pixel budget derived from the container limit rather than picked by feel.
Error: Input buffer has corrupt header: pngload_buffer: Insufficient data to do anything — the object is truncated, usually because a multipart upload was assembled from an incomplete part set or a stream was consumed twice. Not a bomb, and it should be a 400 with a retry hint rather than a security event. Verify the stored object’s byte length against the client’s declared size before you conclude anything about the image itself.
Verification
Build a real over-budget image, prove metadata() reads it without decoding, and prove the pipeline refuses it. This runs in about a second and needs no fixtures:
import sharp from "sharp";
// 3000 × 3000 of one colour. PNG compresses it to roughly 9 KB.
const bomb = await sharp({
create: { width: 3000, height: 3000, channels: 3, background: "#000000" },
})
.png({ compressionLevel: 9 })
.toBuffer();
console.log("on disk:", bomb.byteLength, "bytes"); // on disk: ~9000 bytes
const before = process.memoryUsage.rss();
const meta = await sharp(bomb).metadata();
const after = process.memoryUsage.rss();
console.log(meta.width, meta.height, "rss delta:", after - before);
// 3000 3000 rss delta: a few hundred KB — the header was read, not the pixels.
try {
await sharp(bomb, { limitInputPixels: 4_000_000 }).resize(200).toBuffer();
console.error("FAIL: the 9 MP image decoded under a 4 MP limit");
} catch (err) {
console.log("rejected:", (err as Error).message);
// rejected: Input image exceeds pixel limit
}
Then check the endpoint returns a client error rather than a 502 from a dead worker, and that resident memory never climbed:
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/uploads \
-H 'Content-Type: image/png' --data-binary @bomb.png
# 422
/usr/bin/time -v node ./dist/validate-fixture.js 2>&1 | grep 'Maximum resident'
# Maximum resident set size (kbytes): 118432
A 502, a hung request, or an RSS figure in the gigabytes all mean the same thing: something decoded before the budget check ran.
Frequently Asked Questions
Does metadata() really avoid decoding pixels?
It parses the container header and returns without materialising a raster, so cost is proportional to header size rather than image size. It is not literally free — libvips opens the file and some formats read further than others — but the difference is kilobytes against gigabytes. The observable proof is the RSS delta in the verification snippet above.
Can I just set limitInputPixels and skip the budget check?
You can, and you will be safe, but you will be blind. Input image exceeds pixel limit tells you nothing about how far over the limit the file was, so you cannot distinguish an honest 300 MP scan from a deliberate 4 gigapixel bomb, and you cannot tune the limit from real traffic. Reading the header first costs one extra call and turns every rejection into a data point.
What pixel budget should I actually pick?
Work backwards from the memory limit of the process that decodes. Divide the container’s memory by four bytes per pixel, halve it to leave room for the resize target and the encoder, then divide by your concurrency. A 1024 MB worker running one decode at a time lands near 100 MP; the 50 MP used here leaves headroom for a 16-bit PNG, which libvips holds at more than four bytes per pixel.
Do these limits apply to a Lambda that reads straight from S3?
Yes, and the header lane gets cheaper there: fetch bytes 0–65535 with a ranged GetObject, hand that buffer to metadata(), and you learn the dimensions without transferring the object at all. Only download in full after the budget check passes — a useful pattern in any direct-to-cloud upload flow where the function never sees the upload itself.
Is client-side resizing an alternative to this?
No. Resizing in the browser before upload is a good bandwidth optimisation and a bad security control, because the request that reaches your endpoint is whatever the client chose to send. Keep the client-side resize for the user experience and run the server-side budget anyway.