Skip to main content
interlace
Plugin: node-securityRules

no-unbounded-decompression

Require a maxOutputLength ceiling on zlib one-shot decompression

Detects zlib's buffer-at-once decompressors (gunzip, inflate, unzip, brotliDecompress, zstdDecompress and their *Sync twins) called without a maxOutputLength cap. This rule is part of eslint-plugin-node-security and provides LLM-optimized error messages.

🚨 Security rule | ⚠️ Set to error in recommended

Quick Summary

AspectDetails
CWE ReferenceCWE-409 (Compressed Data)
SeverityHigh (denial of service)
Auto-Fix❌ Manual — only you know the right ceiling
CategorySecurity
ESLint MCP✅ Optimized for ESLint MCP integration
Best ForServices that accept Content-Encoding: gzip bodies or uploaded archives

Vulnerability and Risk

Vulnerability: zlib.gunzip(body, cb) buffers the entire expansion in memory before the callback runs. There is no back-pressure and no ceiling.

Risk: DEFLATE reaches roughly 1000:1 on crafted input, and nested formats do far better. A ~1 KB request body becomes gigabytes of heap and the process dies — one request, no loop, no recursion, nothing a rate limiter would even count as abusive. maxOutputLength makes zlib abort with ERR_BUFFER_TOO_LARGE once output passes the cap, which is the only in-band defence Node offers.

Rule Details

The rule resolves the receiver to what it was imported from, not to what it is called — const z = require('node:zlib') is the same API, and a local helper named gunzip is not. It reports a call when there is no options object at all, or when the options literal carries no maxOutputLength.

It stays quiet where it would be guessing:

  • the options value is opaque (zlib.gunzip(body, opts, cb), { ...defaults }) — the cap may already be in there;
  • the payload is a literal or Buffer.from('<literal>') — a checked-in blob expands to exactly what the author put in it, so no attacker steers it.

Rule partition: the streaming factories (zlib.createGunzip, createUnzip, createInflate) are owned by secure-coding/no-unlimited-resource-allocation. This rule owns only the buffer-at-once entry points, so a given call site has exactly one owner.

Examples

❌ Incorrect

const zlib = require('zlib');

// The whole expansion lands in memory before `cb` runs
function inflateBody(reqBody, cb) {
  zlib.gunzip(reqBody, (err, buf) => cb(err, buf && buf.toString('utf8')));
}

// Sync is worse: it also blocks the event loop while it does it
const plain = zlib.gunzipSync(uploadedBuffer);

// Options present, but no ceiling among them
zlib.gunzip(reqBody, { chunkSize: 4096 }, cb);

✅ Correct

const zlib = require('zlib');
const MAX_OUTPUT = 10 * 1024 * 1024; // 10 MB ceiling

function inflateBody(reqBody, cb) {
  zlib.gunzip(reqBody, { maxOutputLength: MAX_OUTPUT }, (err, buf) => {
    if (err) return cb(err); // ERR_BUFFER_TOO_LARGE lands here
    cb(null, buf.toString('utf8'));
  });
}

const plain = zlib.gunzipSync(uploadedBuffer, { maxOutputLength: MAX_OUTPUT });

Configuration

OptionTypeDefaultDescription
allowInTestsbooleanfalseAllow unbounded decompression in test files
{
  rules: {
    'node-security/no-unbounded-decompression': ['error', {
      allowInTests: false
    }]
  }
}

Security Impact

VulnerabilityCWEOWASPCVSSImpact
Decompression Bomb409A05:20217.5 HighHeap exhaustion, process kill
Uncontrolled Resource Use400A05:20217.5 HighDenial of service

Known False Negatives

Promisified and opaque options

Why: A promisified decompressor is called through a wrapper this rule cannot resolve, and an options object it cannot read may already carry the cap — reporting it would be a guess.

// ❌ NOT DETECTED — the callee is a wrapper, not zlib.gunzip
const gunzipAsync = util.promisify(zlib.gunzip);
await gunzipAsync(reqBody);

// ❌ NOT DETECTED — `opts` may or may not carry maxOutputLength
zlib.gunzip(reqBody, opts, cb);

Mitigation: Define one wrapper that applies the ceiling and route all decompression through it.

Further Reading

⚙️ Options

OptionTypeDefaultDescription
allowInTestsbooleanfalseAllow unbounded decompression in test files

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.