Node.js Decompression Bombs: zlib Limits That Work

A 100 KB gzip body can decompress to 100 MB. Node's maxOutputLength stops that in the one-shot zlib functions, but streams silently ignore it, and fetch() decompresses responses with no cap at all. We tested where the limits hold in Node.js 22 and Express 5, with code you can copy.

Water pouring over a low concrete weir below a calm lake, with a metal railing on one side and bare trees on the far shore
Photo by Deniz Demirci on Unsplash

A decompression bomb is a small compressed payload that expands into far more data than your service expects, enough to exhaust memory or tie up a CPU core. MITRE files it as CWE-409, “Improper Handling of Highly Compressed Data (Data Amplification)”. In Node.js, the defence is to count decompressed bytes and stop at a limit you choose. Node gives you that limit in one place, maxOutputLength on the one-shot zlib functions, and nowhere else. Streams, HTTP clients and custom middleware all need their own cap.

We ran every example below on Node.js 22.23.4. Results for each are in the text.

How big the amplification gets

We compressed 100 MiB (104,857,600 bytes) of zeros with each codec Node ships:

CodecCompressed sizeRatio
gzip (level 9)101,941 bytesabout 1,029 : 1
zstd (default level)3,219 bytesabout 32,575 : 1
Brotli (default quality)172 bytesabout 609,637 : 1

Zeros are the best case for a compressor, and the ratio for real data is far lower. That’s the point: an attacker picks the input. A request-body limit of 1 MB on the wire tells you nothing about the decompressed size.

The one-shot functions: maxOutputLength works

Node’s one-shot functions (zlib.gunzip, zlib.inflate, zlib.brotliDecompress, zlib.zstdDecompress and their *Sync forms) accept maxOutputLength. The zlib docs describe it as an option that “Limits output size when using convenience methods”. The default is buffer.kMaxLength, the largest Buffer Node can allocate, so without it you have no practical limit.

const zlib = require('node:zlib');

const LIMIT = 1024 * 1024; // 1 MiB of decompressed output

function safeGunzip(buf) {
  try {
    return zlib.gunzipSync(buf, { maxOutputLength: LIMIT });
  } catch (err) {
    if (err.code === 'ERR_BUFFER_TOO_LARGE') {
      throw Object.assign(new Error('decompressed payload too large'), { status: 413 });
    }
    throw err; // corrupt input, wrong format, etc.
  }
}

With the 100 MiB bombs from the table, gunzipSync, brotliDecompressSync and zstdDecompressSync all threw RangeError with code ERR_BUFFER_TOO_LARGE and the message “Cannot create a Buffer larger than 1048576 bytes”. The async zlib.gunzip() passed the same error to its callback.

Use the one-shot functions when the whole payload is already in memory and you can name a sensible maximum: a webhook body, a cached blob, a small config file.

Streams: maxOutputLength is ignored

The stream classes accept the same options object, so this looks right:

// Looks safe. Isn't.
zlib.createGunzip({ maxOutputLength: 1024 * 1024 });

In our test, piping the gzip bomb through that stream delivered all 104,857,600 bytes and finished without an error. The option is documented for the convenience methods only, and the stream path doesn’t enforce it. Nothing warns you.

The fix is a Transform that counts bytes after the decompressor and fails the pipeline when the count passes your limit:

const zlib = require('node:zlib');
const { pipeline, Transform } = require('node:stream');

// Fail the pipeline once decompressed output exceeds maxBytes.
function byteLimit(maxBytes) {
  let seen = 0;
  return new Transform({
    transform(chunk, _enc, cb) {
      seen += chunk.length;
      if (seen > maxBytes) {
        const err = new RangeError(`decompressed body exceeds ${maxBytes} bytes`);
        err.code = 'ERR_DECOMPRESSED_TOO_LARGE';
        return cb(err);
      }
      cb(null, chunk);
    },
  });
}

pipeline(
  source,                 // e.g. req, a file stream, an upstream response
  zlib.createGunzip(),
  byteLimit(1024 * 1024),
  destination,
  (err) => { /* err.code === 'ERR_DECOMPRESSED_TOO_LARGE' on a bomb */ },
);

Against the 100 MiB bomb, the pipeline failed with ERR_DECOMPRESSED_TOO_LARGE after exactly 1,048,576 bytes had reached the destination. pipeline() destroys every stream in the chain on error, so the decompressor stops as well.

Two details make this work. The limiter must sit after the decompressor, because counting compressed bytes is the mistake you’re avoiding. And use pipeline(), not chained .pipe() calls, which don’t propagate errors or destroy the upstream streams.

Express: the body parser already counts decompressed bytes

Express’s express.json(), express.urlencoded(), express.text() and express.raw() come from body-parser. Its inflate option defaults to true, and in body-parser 2.4.0 it decodes deflate, gzip, br and, where Node supports it, zstd.

We sent express.json() (default limit of '100kb') a 10 MB JSON body compressed each way:

Content-EncodingBytes on the wireResult
gzip10,232413, entity.too.large
br41413, entity.too.large
zstd355413, entity.too.large
gzip with inflate: false10,232415, encoding.unsupported

All three compressed bodies were under 100 KB on the wire and still got 413, so the limit is applied to the decoded body. That’s the behaviour you want. Tested with Express 5.3.0, body-parser 2.4.0 and raw-body 3.0.2.

Two cautions. The body-parser README warns that limits “above the default, for example payloads of 5 MB or more” increase memory use and response times, so raise limit per route, not globally. And if you don’t expect compressed request bodies, inflate: false turns them away with 415 before any decoding.

Custom code is where the gaps are: a handler that does req.pipe(zlib.createGunzip()) for an upload endpoint, or a framework plugin with its own decoder. Give each one the byte limiter above.

fetch(): responses are decompressed with no cap

Node’s built-in fetch() decodes Content-Encoding: gzip for you. We served the 100 MiB gzip bomb from a local server:

  • res.headers.get('content-length') returned 101941, the compressed size.
  • await res.arrayBuffer() returned 104,857,600 bytes.

There’s no size option on arrayBuffer(), json() or text(). If you fetch URLs you don’t control (webhooks, link previews, feed readers, anything with a user-supplied URL), read the body with a cap:

// Read at most maxBytes of a fetch() response body (after fetch's own decompression).
async function readCapped(res, maxBytes) {
  const reader = res.body.getReader();
  const chunks = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > maxBytes) {
      await reader.cancel();
      throw new RangeError(`response exceeds ${maxBytes} bytes`);
    }
    chunks.push(value);
  }
  return Buffer.concat(chunks);
}

const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const body = await readCapped(res, 1024 * 1024);

Against the bomb, readCapped(res, 1 MiB) threw “response exceeds 1048576 bytes”. Pair it with a timeout, because a slow trickle is a different attack that a byte cap doesn’t stop. Fetching arbitrary URLs has bigger risks than size, too; our SSRF guide covers the network side.

A checklist for Node services

Where you decompressWhat enforces the limitDefault
zlib.*Sync() / zlib.*() one-shotmaxOutputLengthbuffer.kMaxLength (no practical limit)
zlib.create*() streamsYour own byte-counting TransformNone; maxOutputLength is ignored
express.json() and other body-parser parserslimit, counted after decoding'100kb'
fetch() response bodiesA capped readerNone
Uploaded archives (zip, tar.gz)Per-entry and total extracted-size limits in your extractorDepends on the library

Also consider:

  • Keep the decoder patched. Size limits don’t help against bugs in the decompressor itself. CVE-2026-111497 was an out-of-bounds write in Brotli’s decoder, fixed in Node.js 22.23.4, 24.22.1 and 26.12.1; see our Brotli guide.
  • Decide whether you need each codec. zstd arrived in Node.js 22.15.0 and is marked Experimental in the Node 22 docs, yet body-parser 2.4.0 accepts it automatically when the runtime supports it. If clients never send it, inflate: false or a proxy rule removes that path.
  • Push the limit to the edge as well. A reverse proxy that decompresses request bodies should cap the decoded size too, so the application isn’t your only control.

Limits of this article

All results come from Node.js 22.23.4 on Linux with Express 5.3.0, body-parser 2.4.0 and raw-body 3.0.2, using 100 MiB of zeros as the bomb. Other Node release lines, frameworks (Fastify, Koa, NestJS adapters) and HTTP clients (axios, undici’s request) may behave differently. Test them the same way. We measured whether limits held, not peak memory use or CPU time.

Sources and verification

Important claimSourceStatus
maxOutputLength “Limits output size when using convenience methods”; default buffer.kMaxLengthNode.js zlib docs (v22.23.4 source)Verified
zstd added in v22.15.0; Stability 1, ExperimentalNode.js v22.23.4 zlib docsVerified
Compression ratios; ERR_BUFFER_TOO_LARGE; streams ignore maxOutputLength; byte limiter; fetch() resultsOur tests on Node.js 22.23.4 (code above)Verified (tested)
Express limit applies after decompression; 413/415 resultsOur tests on Express 5.3.0 / body-parser 2.4.0Verified (tested)
body-parser decodes deflate, gzip, br, zstd; inflate default true; limit default ‘100kb’; 5 MB warningbody-parser 2.4.0 source and READMEVerified
CWE-409 titleMITRE CWE-409Verified

Frequently asked questions

Does maxOutputLength protect zlib streams in Node.js?

No. The Node.js docs say it limits output size 'when using convenience methods', meaning the one-shot functions such as zlib.gunzip() and zlib.gunzipSync(). In our test on Node.js 22.23.4, zlib.createGunzip({ maxOutputLength: 1048576 }) passed all 104,857,600 bytes through without an error.

Is Express vulnerable to gzip bombs?

Not with its default body parsers. In Express 5.3.0 with body-parser 2.4.0, express.json() counted the decompressed bytes against its 100 KB limit and returned 413 for 10 MB bodies sent compressed as gzip (10,232 bytes), Brotli (41 bytes) and zstd (355 bytes). Raising the limit raises your exposure, and custom middleware that pipes req through zlib needs its own cap.

Is checking Content-Length enough?

No. Content-Length is the compressed size. In our fetch() test the header said 101,941 bytes and the decoded body was 104,857,600 bytes. Count bytes after decompression.

Which Node.js versions support zstd in zlib?

zstd support (zstdCompress, zstdDecompress and the Zstd stream classes) was added in Node.js 22.15.0 and is marked Stability 1, Experimental, in the Node.js 22 documentation.

Node.jsDenial of ServiceAPI SecurityAppSec