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

// 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.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'));

Steps

  1. Step 1.

    Write the fixtures.

    node make-fixtures.mjs
    
    honest.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 196

    Flat colour is the cheapest case for deflate. A photograph of the same size weighs more on disk and decodes to the same memory.

  2. 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 ok

    Width is at byte 16, height at byte 20. The IHDR CRC matches in all three, so CRC validation accepts lie.png as well.

  3. 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 18367347x

    Colour 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.

  4. 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 MiB

    Two 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.

  5. 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 120001

    lie.png promises 3,600,030,000 filtered bytes and carries 120,001, one row. The header does not separate it from huge.png.

  6. 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 201

    The 5 MiB body limit is working, and it reads nothing about pixels.

  7. 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 413

    The response carries the numbers the decision used, and the file was never decoded.

  8. 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 201

    A budget that rejects a 3840 by 2160 photograph is too low for a phone camera.

  9. 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 201

    Both 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

Sign: The upload size limit is set, so the dimension check is reported as covered.Cause: They bound different numbers. Step 6 returned 201 for a 196-byte body declaring 900,000,000 pixels, and step 3 put the decoded size of that body at 3,433.2 MiB. The byte limit multiplied by the worst compression case is the real exposure, and flat colour reached 18,367,347 times its own weight here.
Sign: Width and height are each capped and a 900-megapixel upload is accepted anyway.Cause: Step 9 caps both sides at 30000 and answers 201 for 30000 by 30000. Memory follows the product, so the budget belongs on width times height. Per-side caps are useful for layout, not for allocation.
Sign: A file that passes the header check is treated as measured.Cause: The numbers were written by whoever built the file. lie.png declares 3,600,030,000 filtered bytes, holds 120,001, and carries a valid IHDR CRC, so no validator catches the gap. The header is a pre-filter. Decoding still needs its own ceiling on bytes written.
Sign: The size check lives in the resize code, after the image is loaded.Cause: By then the allocation has happened. Reading the declaration costs nothing: the target here decides on the first 4096 bytes of the body, and step 2 read the size of a 3.4 GiB image out of 33 bytes on disk.

Thresholds

2,147,483,647 is the largest width or height a PNG can declare, and zero is invalid Source: W3C PNG Specification (Third Edition), IHDR chunk: https://www.w3.org/TR/png-3/#11IHDR
40,000,000 pixels, the budget this target enforces: it passed a 3840x2160 photograph at 31.6 MiB and refused 30000x30000 at 3,433.2 MiB Source: MAX_PIXELS in upload-server.mjs above, measured 2026-09-12. See the Verified block.

What to check next

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.

intermediate8 minpublished updated Maks Verny