How to check archive expansion limits on upload

Upload an archive that expands far past its own weight and read the status. curl --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=capped" returned 413 with ERR_BUFFER_TOO_LARGE where a limit was set, and 201 with 104,857,600 bytes expanded where none was. The same 101,941-byte upload produced both answers.

Why check this

An upload route that opens what it receives has two sizes to bound, and most bound one. Every framework ships a body limit. A limit on what those bytes become after decompression is code somebody has to write. The fixture below weighs 101,941 bytes and holds 104,857,600: it passes a 1 MiB upload limit, then asks the process for 100 MB.

Run this before sign-off on any endpoint that takes an archive, a compressed backup or a gzipped import file, and again after a change to the parsing library. The failure it prevents is one request that ends the service: the handler allocates the whole expansion at once and the container hits its memory limit.

Three questions, in order: is there a bound, what comes back when it is hit, and is the partial output removed. Listing an archive before extracting is a separate read, in How to check zip file contents.

Prerequisites

import { createServer } from 'node:http';
import { createWriteStream, existsSync, statSync, unlinkSync } from 'node:fs';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createGunzip, gunzipSync } from 'node:zlib';

const WIRE_CAP = 1024 * 1024;       // what the upload may weigh
const OUTPUT_CAP = 8 * 1024 * 1024; // what it may expand to
const OUT = 'extracted.bin';

const meter = () => {
  let n = 0;
  return new Transform({
    transform(c, _enc, cb) {
      n += c.length;
      if (n > OUTPUT_CAP) { cb(Object.assign(new Error('expansion cap'), { code: 'EXPANSION_CAP' })); return; }
      cb(null, c);
    },
  });
};

createServer(async (req, res) => {
  const mode = new URL(req.url, 'http://127.0.0.1:8934').searchParams.get('mode') || 'naive';
  const chunks = [];
  let wire = 0;
  for await (const c of req) {
    wire += c.length;
    if (wire > WIRE_CAP) { res.writeHead(413).end('over wire cap\n'); return; }
    chunks.push(c);
  }
  const raw = Buffer.concat(chunks);
  const say = (code, body) => {
    res.writeHead(code, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ mode, wire, ...body }) + '\n');
  };
  const rss0 = process.memoryUsage().rss;
  try {
    if (mode === 'naive') {
      const out = gunzipSync(raw);
      say(201, { expanded: out.length, rssGrowthMB: +((process.memoryUsage().rss - rss0) / 1048576).toFixed(1) });
    } else if (mode === 'capped') {
      say(201, { expanded: gunzipSync(raw, { maxOutputLength: OUTPUT_CAP }).length });
    } else if (mode === 'stream') {
      await pipeline(Readable.from(raw), createGunzip({ maxOutputLength: OUTPUT_CAP }), createWriteStream(OUT));
      say(201, { expanded: statSync(OUT).size });
    } else {
      await pipeline(Readable.from(raw), createGunzip(), meter(), createWriteStream(OUT));
      say(201, { expanded: statSync(OUT).size });
    }
  } catch (e) {
    if (mode === 'guard-clean' && existsSync(OUT)) unlinkSync(OUT);
    say(413, { code: e.code, leftOnDisk: existsSync(OUT) ? statSync(OUT).size : null });
  }
}).listen(8934, '127.0.0.1', () => console.log('archive target on 8934'));
node -e "
const fs = require('node:fs'), { gzipSync } = require('node:zlib');
fs.writeFileSync('honest.gz', gzipSync(fs.readFileSync('honest.txt'), { level: 9 }));
console.log('honest.gz', fs.statSync('honest.gz').size, 'bytes from', fs.statSync('honest.txt').size);
"

Steps

  1. Step 1.

    Build the fixture inside a bound you set. Buffer.alloc fixes the uncompressed size at 100 MiB before compression.

    node -e "
    const { gzipSync } = require('node:zlib');
    const raw = Buffer.alloc(100 * 1024 * 1024, 0);
    const gz = gzipSync(raw, { level: 9 });
    require('node:fs').writeFileSync('bomb.gz', gz);
    console.log('uncompressed', raw.length);
    console.log('compressed  ', gz.length);
    console.log('ratio       ', (raw.length / gz.length).toFixed(1) + ':1');
    "
    
    uncompressed 104857600
    compressed   101941
    ratio        1028.6:1

    A hundred kilobytes of upload carry a hundred megabytes of content.

  2. Step 2.

    Read what each file declares before opening it. A gzip member's last four bytes hold its uncompressed size.

    node -e "
    for (const f of ['bomb.gz', 'honest.gz']) {
      const b = require('node:fs').readFileSync(f);
      console.log(f, 'on disk', b.length, 'declares', b.readUInt32LE(b.length - 4));
    }
    "
    
    bomb.gz on disk 101941 declares 104857600
    honest.gz on disk 14073 declares 32354

    A first filter, not a bound: those four bytes were chosen by whoever built the file.

  3. Step 3.

    Send the fixture to the route that extracts with no limit.

    curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=naive" -w 'HTTP %{http_code}\n'
    
    {"mode":"naive","wire":101941,"expanded":104857600,"rssGrowthMB":207}
    HTTP 201

    wire is what the body limit saw, expanded is what the process allocated. Resident memory grew by 207 MB for a 100 MB output: the copy and zlib's buffer are held at once.

  4. Step 4.

    Send the same file to the route that passes a limit to zlib.

    curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=capped" -w 'HTTP %{http_code}\n'
    
    {"mode":"capped","wire":101941,"code":"ERR_BUFFER_TOO_LARGE","leftOnDisk":null}
    HTTP 413

    ERR_BUFFER_TOO_LARGE is what Node's zlib raises when maxOutputLength is passed. The allocation never happened.

  5. Step 5.

    Send the honest archive to the same route, to show what the limit lets through.

    curl -s --data-binary @honest.gz "http://127.0.0.1:8934/upload?mode=capped" -w 'HTTP %{http_code}\n'
    
    {"mode":"capped","wire":14073,"expanded":32354}
    HTTP 201

    A limit that rejects this archive is set too low.

  6. Step 6.

    Send the fixture to the route that streams to disk with the same option.

    curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=stream" -w 'HTTP %{http_code}\n'
    
    {"mode":"stream","wire":101941,"expanded":104857600}
    HTTP 201

    The option that stopped step 4 stopped nothing here. expanded comes from statSync on the written file: 104,857,600 bytes under a cap of 8,388,608.

  7. Step 7.

    Send it to the route that counts the bytes leaving the decompressor.

    curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=guard" -w 'HTTP %{http_code}\n'
    
    {"mode":"guard","wire":101941,"code":"EXPANSION_CAP","leftOnDisk":8388608}
    HTTP 413

    The status is right and the disk is not. leftOnDisk is 8,388,608 bytes of a refused upload that nothing removes.

  8. Step 8.

    Send it to the route that unlinks the partial file in the error path.

    curl -s --data-binary @bomb.gz "http://127.0.0.1:8934/upload?mode=guard-clean" -w 'HTTP %{http_code}\n'
    
    {"mode":"guard-clean","wire":101941,"code":"EXPANSION_CAP","leftOnDisk":null}
    HTTP 413

    Same status, same code, nothing left behind. One line of server code separates it from step 7.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 201 and an expanded size far above the upload size | Nothing bounds expansion | Raise it as a defect and attach the ratio from step 1. | | 413 or 400 with a code naming the limit | Expansion is bounded | Repeat with the honest archive, as in step 5, to find where the limit sits. | | 413 and a partial file still present | The bound fires and the cleanup does not | Report the leftover path and size. Repeated uploads fill the volume. | | 500, or the connection drops with no status | The process hit its own ceiling | Read the service log for an out of memory kill. This is the outage the check is for. | | The same file rejected before extraction starts | A body limit answered, not an expansion limit | Shrink the fixture below the body limit and run it again. |

Common mistakes

Sign: maxOutputLength is set on the decompressor and a bomb still lands in full.Cause: It bounds the convenience methods. gunzipSync with maxOutputLength threw ERR_BUFFER_TOO_LARGE in step 4, and createGunzip with the same value wrote 104,857,600 bytes in step 6 and answered 201. A stream is bounded by counting what leaves it, which is what mode=guard does.
Sign: The upload size limit is treated as the protection, so no expansion test is run.Cause: The two limits measure different bytes. Every response above reports wire 101,941, inside the target's 1 MiB body limit, next to an expansion of 104,857,600. A body limit multiplied by the ratio in step 1 is the real exposure, and at 1028.6:1 a 1 MiB limit allows about a gigabyte.
Sign: The service reads the declared uncompressed size, rejects anything too big, and reports the check as passed.Cause: The declared size is part of the file. Rewriting the last four bytes of bomb.gz to 1024 and posting it to mode=guard still produced EXPANSION_CAP with 8,388,608 bytes written, because the content did not change. A declared size is a fast pre-filter ahead of a real counter, not a replacement.
Sign: The test passes because a 413 came back, and the volume fills up in staging a week later.Cause: Steps 7 and 8 return the same status and the same code. The difference is on disk, and only the JSON field leftOnDisk shows it. Check the extraction directory after a rejected upload, or the finding is invisible from the response.

Thresholds

1028.6:1, the expansion measured on 100 MiB of zero bytes at gzip level 9 Source: Measured on this machine, 2026-09-12. See the Verified block.
8 MiB, the expansion ceiling the sitemap checker in this repository sets Source: MAX_DECOMPRESSED_BYTES in packages/checkers-api/api/robots-sitemap.ts, passed to gunzipSync as maxOutputLength.

What to check next

FAQ

What is a zip bomb?

An archive whose contents are far larger than the archive. Compression is good at repetition: 104,857,600 zero bytes gzipped to 101,941 in step 1. Whatever opens it without a limit does the damage.

How do I test a zip bomb safely?

Fix the uncompressed size in advance, as step 1 does with Buffer.alloc, keep it on a local target, and delete it after the run. A fixture expanding to 100 MB proves the same point as one expanding to a terabyte.

Does a file size limit stop a decompression bomb?

No. Every run above weighed 101,941 bytes on the wire. The limit that matters is on the decompressor's output, enforced while extraction runs.

Which status code should the service return?

413 with a code in the body naming the limit, as in steps 4, 7 and 8. A 500 means the process was surprised. More important than the number: the rejection leaves no partial file.

Does this apply to zip as well as gzip?

Yes. A zip declares an uncompressed size per entry, written by whoever built the file, like the gzip trailer in step 2. Extraction still needs a counter.

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.

intermediate10 minpublished updated Maks Verny