How to test image dimension limits on upload
Read the size a file declares before anything decodes it. In this run a 196-byte PNG declared 30000 by 30000 in its IHDR, which is 900,000,000 pixels and 3,433.2 MiB of decoded memory. The local target answered 201 under a byte limit and 413 under a pixel limit.
Why check this
An image upload has two sizes. One is on the wire, and every framework bounds it. The other is width times height times bytes per pixel, and only code somebody writes bounds that. The fixture in step 1 weighs 196 bytes and declares 900 million pixels.
Run this before sign-off on any route that stores or resizes an image, and again after the imaging library changes. The failure it prevents is one request that ends the worker: the decoder allocates for the declared size and the container reaches its memory ceiling.
Dimensions sit in the header, ahead of the pixel data: IHDR is a PNG's first chunk and IDAT follows it. An archive's compression ratio is a different multiplication, covered in How to check archive expansion limits on upload. This one runs from declared pixels to memory.
Prerequisites
- Node 22 for the two scripts below. No imaging library: headers are read with
Buffermethods. - curl 8.0 or later for steps 6 to 9.
- One JPEG of your own copied to
photo.jpg, here a 3840 by 2160 Windows 11 sample. - Nothing here decodes a fixture. The steps read bytes and multiply, and the one inflate in step 5 is capped at 64 MiB.
make-fixtures.mjswrites three PNGs: one ordinary, one enormous, one that declares more than it holds. Rows stream into the compressor, so the largest allocation is one row.
// make-fixtures.mjs
import { writeFileSync, statSync } from 'node:fs';
import { crc32, createDeflate } from 'node:zlib';
import { Readable } from 'node:stream';
import { buffer } from 'node:stream/consumers';
const chunk = (type, data) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
};
async function png(file, w, h, rows, gradient) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; // bits per channel
ihdr[9] = 6; // colour type 6 = RGBA, four bytes a pixel
function* lines() {
const row = Buffer.alloc(1 + w * 4); // one filter byte, then the pixels
for (let y = 0; y < rows; y++) {
if (gradient) for (let x = 0; x < w; x++) {
row[1 + x * 4] = (x * 255 / w) | 0;
row[2 + x * 4] = (y * 255 / h) | 0;
row[3 + x * 4] = 128;
row[4 + x * 4] = 255;
}
yield Buffer.from(row); // rows are streamed, never held together
}
}
const idat = await buffer(Readable.from(lines()).pipe(createDeflate({ level: 9 })));
writeFileSync(file, Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)),
]));
console.log(file.padEnd(10), 'declares', (w + 'x' + h).padEnd(11),
'rows written', String(rows).padStart(5), 'file bytes', statSync(file).size);
}
await png('honest.png', 800, 600, 600, true);
await png('huge.png', 30000, 30000, 30000, false);
await png('lie.png', 30000, 30000, 1, false);
upload-server.mjsis the target. Runnode upload-server.mjs. It reads the first 4096 bytes of a body, never decodes, and answers?mode=bytes,?mode=pixelsand?mode=sides.
// upload-server.mjs
import { createServer } from 'node:http';
const BYTE_CAP = 5 * 1024 * 1024; // what the upload may weigh
const MAX_PIXELS = 40_000_000; // what it may decode to
const MAX_SIDE = 30000; // a per-side cap, on its own
const BYTES_PER_PIXEL = 4; // 8-bit RGBA, what colour type 6 declares
const declared = (b) => {
if (b.length > 26 && b.readUInt32BE(0) === 0x89504e47) {
return { kind: 'png', w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
}
if (b.length > 10 && b[0] === 0xff && b[1] === 0xd8) {
let o = 2;
while (o < b.length - 9 && b[o] === 0xff) {
const m = b[o + 1];
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
return { kind: 'jpeg', w: b.readUInt16BE(o + 7), h: b.readUInt16BE(o + 5) };
}
o += 2 + b.readUInt16BE(o + 2);
}
}
return null;
};
createServer(async (req, res) => {
const mode = new URL(req.url, 'http://127.0.0.1:8932').searchParams.get('mode') || 'bytes';
const chunks = [];
let bytes = 0;
for await (const c of req) {
bytes += c.length;
if (bytes > BYTE_CAP) { res.writeHead(413).end('over byte cap\n'); return; }
chunks.push(c);
}
const head = Buffer.concat(chunks).subarray(0, 4096);
const say = (code, body) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify({ mode, bytes, ...body }) + '\n');
};
const d = declared(head);
if (!d) { say(415, { error: 'no png or jpeg header' }); return; }
const size = d.w + 'x' + d.h;
const pixels = d.w * d.h;
const memoryMB = +((pixels * BYTES_PER_PIXEL) / 1048576).toFixed(1);
if (mode === 'bytes') { say(201, { stored: true }); return; }
if (mode === 'sides') {
if (d.w > MAX_SIDE || d.h > MAX_SIDE) { say(413, { declared: size, error: 'side over ' + MAX_SIDE }); return; }
say(201, { declared: size, pixels, memoryMB });
return;
}
if (pixels > MAX_PIXELS) { say(413, { declared: size, pixels, memoryMB, limitPixels: MAX_PIXELS }); return; }
say(201, { declared: size, pixels, memoryMB });
}).listen(8932, '127.0.0.1', () => console.log('dimension target on 8932'));
- Delete the four files afterwards and stop the server by its process id.
Steps
- Step 1.
Write the fixtures.
node make-fixtures.mjshonest.png declares 800x600 rows written 600 file bytes 114226 huge.png declares 30000x30000 rows written 30000 file bytes 3499119 lie.png declares 30000x30000 rows written 1 file bytes 196Flat colour is the cheapest case for deflate. A photograph of the same size weighs more on disk and decodes to the same memory.
- Step 2.
Read what each file declares.
node -e " const fs = require('node:fs'), { crc32 } = require('node:zlib'); for (const f of ['honest.png', 'huge.png', 'lie.png']) { const fd = fs.openSync(f, 'r'); const h = Buffer.alloc(33); fs.readSync(fd, h, 0, 33, 0); fs.closeSync(fd); const w = h.readUInt32BE(16), ht = h.readUInt32BE(20); const crcOk = crc32(h.subarray(12, 29)) === h.readUInt32BE(29); console.log(f.padEnd(10), 'disk', String(fs.statSync(f).size).padStart(7), 'declares', (w + 'x' + ht).padEnd(11), 'depth', h[24], 'colour', h[25], 'pixels', (w * ht).toLocaleString('en-US').padStart(11), 'IHDR crc', crcOk ? 'ok' : 'bad'); } "honest.png disk 114226 declares 800x600 depth 8 colour 6 pixels 480,000 IHDR crc ok huge.png disk 3499119 declares 30000x30000 depth 8 colour 6 pixels 900,000,000 IHDR crc ok lie.png disk 196 declares 30000x30000 depth 8 colour 6 pixels 900,000,000 IHDR crc okWidth is at byte 16, height at byte 20. The IHDR CRC matches in all three, so CRC validation accepts
lie.pngas well. - Step 3.
Turn each declaration into the memory it asks for.
node -e " const fs = require('node:fs'); const BPP = 4; for (const f of ['honest.png', 'huge.png', 'lie.png']) { const fd = fs.openSync(f, 'r'); const h = Buffer.alloc(26); fs.readSync(fd, h, 0, 26, 0); fs.closeSync(fd); const need = h.readUInt32BE(16) * h.readUInt32BE(20) * BPP; const disk = fs.statSync(f).size; console.log(f.padEnd(10), 'disk', String(disk).padStart(7), 'bytes decoded', (need / 1048576).toFixed(1).padStart(7), 'MiB multiplier', Math.round(need / disk) + 'x'); } "honest.png disk 114226 bytes decoded 1.8 MiB multiplier 17x huge.png disk 3499119 bytes decoded 3433.2 MiB multiplier 1029x lie.png disk 196 bytes decoded 3433.2 MiB multiplier 18367347xColour type 6 at depth 8 is four one-byte channels, so the file asks for four bytes a pixel. The multiplier is what a byte limit cannot see.
- Step 4.
Read the same claim out of a JPEG, in the SOF marker.
node -e " const fs = require('node:fs'); const b = fs.readFileSync('photo.jpg'); let o = 2; while (o < b.length - 9 && b[o] === 0xff) { const m = b[o + 1]; if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) { const h = b.readUInt16BE(o + 5), w = b.readUInt16BE(o + 7); console.log('photo.jpg disk', fs.statSync('photo.jpg').size, 'bytes'); console.log('SOF marker ff' + m.toString(16), 'at offset', o, ' precision', b[o + 4], 'bits components', b[o + 9]); console.log('declares ', w + 'x' + h, ' pixels', (w * h).toLocaleString('en-US'), ' decoded at 4 bytes a pixel', ((w * h * 4) / 1048576).toFixed(1), 'MiB'); break; } o += 2 + b.readUInt16BE(o + 2); } "photo.jpg disk 1033330 bytes SOF marker ffc0 at offset 158 precision 8 bits components 3 declares 3840x2160 pixels 8,294,400 decoded at 4 bytes a pixel 31.6 MiBTwo 16-bit fields two bytes apart return the file's real size, which caps a JPEG side at 65,535. The marker sits at offset 158, ahead of the scan.
- Step 5.
Compare each declaration with the pixel data present.
node -e " const fs = require('node:fs'), { inflateSync } = require('node:zlib'); const CAP = 64 * 1024 * 1024; for (const f of ['honest.png', 'huge.png', 'lie.png']) { const b = fs.readFileSync(f); let o = 8; const idat = []; while (o < b.length) { const len = b.readUInt32BE(o), type = b.toString('latin1', o + 4, o + 8); if (type === 'IDAT') idat.push(b.subarray(o + 8, o + 8 + len)); o += 12 + len; } const declared = (b.readUInt32BE(16) * 4 + 1) * b.readUInt32BE(20); let got; try { got = inflateSync(Buffer.concat(idat), { maxOutputLength: CAP }).length + ''; } catch (e) { got = 'over the 64 MiB cap (' + e.code + ')'; } console.log(f.padEnd(10), 'IHDR needs', String(declared).padStart(13), 'filtered bytes IDAT holds', got); } "honest.png IHDR needs 1920600 filtered bytes IDAT holds 1920600 huge.png IHDR needs 3600030000 filtered bytes IDAT holds over the 64 MiB cap (ERR_BUFFER_TOO_LARGE) lie.png IHDR needs 3600030000 filtered bytes IDAT holds 120001lie.pngpromises 3,600,030,000 filtered bytes and carries 120,001, one row. The header does not separate it fromhuge.png. - Step 6.
Send the 196-byte file to a route whose only limit is on bytes.
curl -s --data-binary @lie.png "http://127.0.0.1:8932/upload?mode=bytes" -w 'HTTP %{http_code}\n'{"mode":"bytes","bytes":196,"stored":true} HTTP 201The 5 MiB body limit is working, and it reads nothing about pixels.
- Step 7.
Send the same file to the route that budgets pixels.
curl -s --data-binary @lie.png "http://127.0.0.1:8932/upload?mode=pixels" -w 'HTTP %{http_code}\n'{"mode":"pixels","bytes":196,"declared":"30000x30000","pixels":900000000,"memoryMB":3433.2,"limitPixels":40000000} HTTP 413The response carries the numbers the decision used, and the file was never decoded.
- Step 8.
Send a real photograph to the same route.
curl -s --data-binary @photo.jpg "http://127.0.0.1:8932/upload?mode=pixels" -w 'HTTP %{http_code}\n'{"mode":"pixels","bytes":1033330,"declared":"3840x2160","pixels":8294400,"memoryMB":31.6} HTTP 201A budget that rejects a 3840 by 2160 photograph is too low for a phone camera.
- Step 9.
Send the enormous file to a route that caps each side.
curl -s --data-binary @huge.png "http://127.0.0.1:8932/upload?mode=sides" -w 'HTTP %{http_code}\n'{"mode":"sides","bytes":3499119,"declared":"30000x30000","pixels":900000000,"memoryMB":3433.2} HTTP 201Both sides are inside a 30000 cap, so the route accepts 900,000,000 pixels. Two caps that each pass still multiply.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | 201 with no declared size in the response | The route stored the file without reading the header | Ask where dimensions are read. If the answer is the resize worker, the budget sits after the allocation. | | 413 naming pixels and a limit | The declaration is bounded before any decode | Repeat with a real photograph, as in step 8, to find where the budget sits. | | 201 for 30000 by 30000 under per-side caps | The limits are on the sides, not on their product | Report the accepted pixel count. Two caps of 30000 allow 900 million pixels. | | 500, or the connection closes with no status | The decode started and the process ran out of memory | Read the service log for an out of memory kill. This is the outage the check prevents. | | 415 or 400 before any size appears | The type check answered first | Repeat with a type the route accepts, then read How to check if file type is validated on the server. |
Common mistakes
Thresholds
What to check next
- How to check archive expansion limits on upload: the same bound on compression ratio.
- How to test file upload size limit: the byte limit step 6 passed.
- How to check if file type is validated on the server: content or extension.
- How to check exif data is removed from an uploaded image: the other header data that is stored.
FAQ
How do I check image dimensions without an imaging library?
Read the header. In a PNG, bytes 16 to 20 are width and 20 to 24 are height, big endian (step 2). In a JPEG, read the two 16-bit fields in the SOF marker (step 4).
What dimension limit should an upload route set?
A budget on width times height, not one cap per side. The target here allows 40,000,000 pixels and passed a 3840 by 2160 photograph at 31.6 MiB. Set yours from what one worker can hold.
Is a file size limit enough?
No. Step 6 shows 196 bytes accepted under a 5 MiB limit while declaring 900,000,000 pixels. Wire bytes and decoded pixels are separate numbers.
Can a header declare a size the file does not contain?
Yes. lie.png declares 3,600,030,000 filtered bytes and holds 120,001 with a correct IHDR CRC (steps 2 and 5). Treat it as a claim to filter on, and keep a byte ceiling on the decode.
Which status code should the route return?
413 with the declared size in the body, as in step 7. It names the number the decision used. A 500 means the decode had started.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny