Detecting and Blocking Zip Bomb Uploads

Read the central directory and reject anything whose declared expansion is implausible, then extract through a byte budget shared by the whole archive tree that aborts the inflate the instant real output overtakes what the archive promised.

This article sits under upload rate limiting and abuse protection within backend validation and cloud storage architecture. It assumes the file has already arrived and been identified as an archive; the identification step belongs to server-side file validation.

When to use this approach

  • You unpack user-supplied archives server-side — a ZIP of photos, a .docx you read XML out of, an SDK bundle, a dataset drop — and the extraction runs on a host that other requests share.
  • You need a bounded worst case rather than a heuristic: “this job will never write more than 512 MiB and never open more than 10 000 files” is something you can size a disk and a timeout around.
  • Your scanner cannot help. ClamAV flags a handful of known bombs by hash, but a freshly generated one is just deflate output. Depth and byte accounting are structural checks; signature matching is not.

If you never extract — you store the archive and hand it back verbatim — you do not need any of this. A size cap at the edge is enough.

Prerequisites

  1. Node 20+ with yauzl 3.2.0 (npm i yauzl @types/yauzl). Any library works if it exposes the central directory before decompressing; adm-zip does not, because it inflates into memory on open.
  2. The archive on local disk or on a filesystem you can seek. The ZIP central directory lives at the end of the file, so a front-to-back stream cannot be validated before it is decompressed.
  3. A scratch directory on its own volume with a quota — tmpfs with size=1G is ideal. Budgets in code are the primary control; the quota is what saves you when the code is wrong.
  4. Extraction running in a worker process you can kill, not in the request handler.
  5. Numbers you have measured on your own corpus: the ninety-ninth percentile entry count, uncompressed total, and expansion ratio of archives real users send.

Why the declared size is not a limit

Every ZIP entry carries its uncompressed size in up to three places: the local file header that precedes the compressed bytes, an optional data descriptor that follows them when general-purpose bit 3 is set, and the central directory record at the end of the file. Extractors trust the central directory, because that is the only index the format provides. Nothing in the specification requires the three to agree, and no extractor can check them before doing the work — the local header is a claim, the central directory is a claim, and the compressed stream is the ground truth.

Where a ZIP entry records its uncompressed size A byte layout of a ZIP file showing local headers, deflate streams, the central directory and the end-of-central-directory record, with the local header and central directory both declaring one megabyte while the deflate stream actually emits four gigabytes. One entry, two claims, one truth Local header deflate stream Local header deflate stream Central dir EOCD local header says 1 048 576 bytes central directory says (extractors read this) 1 048 576 bytes inflate emits 4 294 967 296 bytes the format cross-checks none of this
Both size fields are attacker-controlled integers; only the byte counter on the inflate output is not.

That gives you two separate problems, and they need two separate defences. An honest bomb declares the truth — 42.zip is 42 KB of central directory records that faithfully announce 4.5 PB across five levels of nesting — and a preflight sum catches it before a single byte is inflated. A forged bomb declares 1 MiB and emits 4 GiB, sailing straight past the preflight. Only a counter on the actual output stops that one, and it has to abort, not report: by the time the entry finishes you have already written the bytes.

Ratio alone is a weak signal in both directions. DEFLATE tops out at 1032:1 for a single stream, so anything above that must be nesting or overlapping entries — but a tar of JSON logs legitimately hits 90:1, and a sparse VM image far more. Treat the ratio as a cheap early exit, never as the whole answer.

Implementation

Two gates, one counter. Gate 1 reads the central directory and rejects on the archive’s own numbers: entry count, per-entry size, declared total, expansion ratio, path shape. Gate 2 turns the surviving declaration into a spending limit and meters every byte the inflate produces against it, aborting the pipeline on the first byte that overshoots.

Two-gate extraction pipeline An uploaded archive passes a central-directory preflight that costs no decompression, then a streaming extract metered against a shared byte budget; either gate can divert the archive to a rejection. archive on scratch disk Gate 1: preflight central directory only zero bytes inflated Gate 2: meter counts real output aborts mid-entry extracted within budget ArchiveRejected scratch dir removed overshoot declared total or ratio over cap
The preflight is free and catches honest bombs; the meter costs one addition per chunk and catches forged ones.
// guarded-unzip.ts — Node 20+, "type": "module"
import { createWriteStream } from "node:fs";
import { mkdir, rm, stat } from "node:fs/promises";
import { dirname, join, normalize, sep } from "node:path";
import { Transform, type TransformCallback } from "node:stream";
import { pipeline } from "node:stream/promises";
import yauzl, { type Entry, type ZipFile } from "yauzl";

export interface Limits {
  maxTotalBytes: number;  // across the whole tree, nested archives included
  maxEntryBytes: number;  // any single member
  maxEntries: number;     // central directory records
  maxRatio: number;       // declared uncompressed / archive bytes on disk
  maxDepth: number;       // 0 = refuse every nested archive
}

export const DEFAULT_LIMITS: Limits = {
  maxTotalBytes: 512 * 1024 * 1024,
  maxEntryBytes: 128 * 1024 * 1024,
  maxEntries: 10_000,
  maxRatio: 120,
  maxDepth: 2,
};

export class ArchiveRejected extends Error {
  constructor(readonly code: string, message: string) {
    super(message);
    this.name = "ArchiveRejected";
  }
}

interface Budget { spent: number; cap: number }

const NESTED = /\.(zip|jar|war|apk|docx|xlsx|pptx|odt|epub)$/i;

/** Refuse NUL bytes, drive letters, absolute paths and any leading `..`. */
function safeRelativePath(name: string): string {
  if (name.includes("\u0000")) {
    throw new ArchiveRejected("entry_path", `entry name contains a NUL byte: ${JSON.stringify(name)}`);
  }
  const slashed = name.replace(/\\/g, "/");
  if (slashed.startsWith("/") || /^[a-zA-Z]:/.test(slashed)) {
    throw new ArchiveRejected("entry_path", `absolute entry path refused: ${slashed}`);
  }
  const rel = normalize(slashed);
  if (rel === ".." || rel.startsWith(`..${sep}`)) {
    throw new ArchiveRejected("entry_path", `path traversal refused: ${name}`);
  }
  return rel;
}

/** Unix mode lives in the high 16 bits of externalFileAttributes. */
function isSymlink(entry: Entry): boolean {
  return ((entry.externalFileAttributes >>> 16) & 0o170000) === 0o120000;
}

class BudgetMeter extends Transform {
  entryBytes = 0;
  constructor(
    private readonly budget: Budget,
    private readonly entry: Entry,
    private readonly maxEntryBytes: number,
  ) {
    super();
  }
  _transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
    this.entryBytes += chunk.length;
    this.budget.spent += chunk.length;
    if (this.entryBytes > this.entry.uncompressedSize) {
      cb(new ArchiveRejected("size_mismatch",
        `entry "${this.entry.fileName}" declared ${this.entry.uncompressedSize} bytes ` +
        `but has already produced ${this.entryBytes}; central directory is forged`));
      return;
    }
    if (this.entryBytes > this.maxEntryBytes) {
      cb(new ArchiveRejected("entry_too_large",
        `entry "${this.entry.fileName}" produced ${this.entryBytes} bytes, over maxEntryBytes=${this.maxEntryBytes}`));
      return;
    }
    if (this.budget.spent > this.budget.cap) {
      cb(new ArchiveRejected("budget_exhausted",
        `extraction budget exhausted at entry "${this.entry.fileName}": ` +
        `wrote ${this.budget.spent} bytes against a cap of ${this.budget.cap}`));
      return;
    }
    cb(null, chunk);
  }
}

function openZip(file: string): Promise<ZipFile> {
  return new Promise((resolve, reject) => {
    // autoClose:false keeps the handle alive after the central directory walk.
    // validateEntrySizes:false because BudgetMeter does that job, and more.
    yauzl.open(file, { lazyEntries: true, autoClose: false, validateEntrySizes: false },
      (err, zip) => (err || !zip ? reject(err ?? new Error("yauzl returned no ZipFile")) : resolve(zip)));
  });
}

function readCentralDirectory(zip: ZipFile, maxEntries: number): Promise<Entry[]> {
  return new Promise((resolve, reject) => {
    const out: Entry[] = [];
    zip.on("error", reject);
    zip.on("end", () => resolve(out));
    zip.on("entry", (entry: Entry) => {
      if (out.length >= maxEntries) {
        reject(new ArchiveRejected("entry_count", `central directory holds more than ${maxEntries} entries`));
        return;
      }
      out.push(entry);
      zip.readEntry();
    });
    zip.readEntry();
  });
}

function openEntryStream(zip: ZipFile, entry: Entry): Promise<NodeJS.ReadableStream> {
  return new Promise((resolve, reject) => {
    zip.openReadStream(entry, (err, stream) =>
      (err || !stream ? reject(err ?? new Error(`no read stream for ${entry.fileName}`)) : resolve(stream)));
  });
}

export async function extractGuarded(
  zipPath: string,
  destDir: string,
  opts: { limits?: Partial<Limits>; depth?: number; budget?: Budget } = {},
): Promise<{ entries: number; bytes: number }> {
  const limits: Limits = { ...DEFAULT_LIMITS, ...opts.limits };
  const depth = opts.depth ?? 0;
  if (depth > limits.maxDepth) {
    throw new ArchiveRejected("nesting_depth",
      `nested archive at depth ${depth} exceeds maxDepth=${limits.maxDepth}: ${zipPath}`);
  }

  const archiveBytes = (await stat(zipPath)).size;
  const zip = await openZip(zipPath);

  try {
    const entries = await readCentralDirectory(zip, limits.maxEntries);

    // ── Gate 1: judge the archive on its own declarations. No inflate yet.
    let declared = 0;
    for (const entry of entries) {
      if (entry.fileName.endsWith("/")) continue;
      if (isSymlink(entry)) {
        throw new ArchiveRejected("entry_symlink", `symlink entry refused: ${entry.fileName}`);
      }
      safeRelativePath(entry.fileName);
      if (entry.uncompressedSize > limits.maxEntryBytes) {
        throw new ArchiveRejected("entry_too_large",
          `entry "${entry.fileName}" declares ${entry.uncompressedSize} bytes, over maxEntryBytes=${limits.maxEntryBytes}`);
      }
      declared += entry.uncompressedSize;
    }
    const ratio = declared / Math.max(archiveBytes, 1);
    if (declared > limits.maxTotalBytes || ratio > limits.maxRatio) {
      throw new ArchiveRejected("expansion_ratio",
        `archive declares ${declared} bytes from ${archiveBytes} on disk (${ratio.toFixed(1)}:1), ` +
        `over maxRatio=${limits.maxRatio} / maxTotalBytes=${limits.maxTotalBytes}`);
    }

    // ── Gate 2: the declaration becomes the spending limit, not a promise.
    //    One Budget object is threaded through every nested archive.
    const budget: Budget = opts.budget ?? { spent: 0, cap: Math.min(declared, limits.maxTotalBytes) };

    for (const entry of entries) {
      if (entry.fileName.endsWith("/")) continue;
      const rel = safeRelativePath(entry.fileName);
      const target = join(destDir, rel);
      await mkdir(dirname(target), { recursive: true });
      const source = await openEntryStream(zip, entry);
      const meter = new BudgetMeter(budget, entry, limits.maxEntryBytes);
      // "wx" refuses a name an earlier entry already claimed (duplicates are legal in ZIP).
      await pipeline(source, meter, createWriteStream(target, { flags: "wx", mode: 0o600 }));
      if (NESTED.test(rel)) {
        await extractGuarded(target, `${target}.d`, { limits, depth: depth + 1, budget });
      }
    }
    return { entries: entries.length, bytes: budget.spent };
  } catch (err) {
    await rm(destDir, { recursive: true, force: true });
    throw err;
  } finally {
    zip.close();
  }
}

Line by line, the parts that carry the weight

  • lazyEntries: true makes yauzl hand you one central directory record at a time instead of buffering all of them. With maxEntries checked inside the handler, an archive claiming forty million entries costs you ten thousand records, not forty million.
  • autoClose: false is mandatory here. With the default, yauzl closes the file handle once the entry walk ends, and the first openReadStream afterwards fails with Error: closed. The finally block closes it instead.
  • validateEntrySizes: false replaces yauzl’s own per-entry check rather than removing it. BudgetMeter enforces the same declared-size rule, plus a hard per-entry ceiling and a tree-wide pot, and raises one error type with one message format that you can log and alert on.
  • cap: Math.min(declared, limits.maxTotalBytes) is the idea the whole page rests on. An honest archive writes exactly declared bytes, so binding the budget to the declaration costs nothing legitimate — and turns a forged central directory into an abort at the first surplus chunk instead of a signal you get after the disk is full.
  • budget is passed down, limits is passed down, depth increments. A nested archive inherits the parent’s remaining balance. Ten archives of 60 MiB each nested inside one wrapper cannot each spend 512 MiB.
  • pipeline rather than .pipe() matters at abort time: when the meter calls back with an error, pipeline destroys the inflate stream and the write stream. .pipe() would leave the inflate running to completion in the background, which is precisely the resource exhaustion you are defending against.
  • safeRelativePath runs twice, once in the preflight over all entries and again immediately before join. The second call is the one that matters; the first is what lets you reject the archive before creating any files at all.
  • isSymlink closes the trick that path checking alone misses: entry one is a symlink named config pointing at /etc/cron.d/, entry two is a plain file named config/job, and every path in the archive is relative. Refusing symlinks outright is almost always right for uploaded content.
  • flags: "wx" turns duplicate entry names into EEXIST rather than a silent overwrite, which is how “the archive I inspected is not the archive that landed on disk” bugs happen.
  • rm(destDir, { recursive: true, force: true }) in the catch. Partial extractions are worse than none: a downstream job that lists the directory will happily process half a tree.

Nested archives and the depth budget

Depth is a multiplier, not an addition. 42.zip is five levels of sixteen files, each level fully honest about its contents, ending at 4.5 PB from 42 KB. Every level you allow multiplies the worst case by the branching factor, so maxDepth is the difference between a bounded job and an unbounded one.

Expansion by nesting depth with a depth cap Five nesting levels each multiplying the archive count by sixteen, from 42 kilobytes at depth zero to petabytes at depth five, with a cut line refusing anything past depth two. Each level multiplies by the branching factor depth 0 1 archive 42 KB depth 1 16 archives 0.6 MB depth 2 256 archives 9.8 MB depth 3 4 096 157 MB depth 5 1 048 576 4.5 PB maxDepth = 2 refuse and stop walking Depth alone is not enough: overlapped-entry bombs reach 28 000 000:1 at depth 1.
The depth cap bounds the multiplier; the declared-total check is what handles flat bombs that never nest at all.

A maxDepth of 2 covers nearly all legitimate content — a .docx inside a submissions ZIP is depth 1, and an SDK bundle containing a JAR is depth 2. Set it to 0 unless you have a concrete reason. And note the limit of the depth idea: flat bombs built from overlapping local file headers reach roughly 28 000 000:1 without nesting at all, because hundreds of central directory entries point at one shared deflate stream. Depth does nothing there; the declared total is what catches it, since those entries add up honestly to hundreds of terabytes.

Record the outcome for every rejected archive — code, declared bytes, ratio, entry count, the account that uploaded it — in the same table you use for file metadata in PostgreSQL. Three expansion_ratio rejections from one account in a minute is a rate-limiting signal, not just a failed job — feed it into the same counters that govern rate limiting presigned URL issuance so the account stops receiving upload credentials at all.

Configuration gotchas

Error: end of central directory record signature not found — yauzl looked for the EOCD marker in the last 64 KiB and did not find it. Three real causes: the upload is not a ZIP (check the magic bytes first, per detecting file type from magic bytes in JavaScript); the transfer truncated; or the ZIP has a comment longer than 64 KiB. Treat it as a client error, not a 500.

ArchiveRejected [expansion_ratio] on legitimate uploads. A default maxRatio of 120 will reject archives of CSV exports and log bundles, which routinely clear 200:1. Log the observed ratio in shadow mode for two weeks, take the ninety-ninth percentile per content class, and set the limit above it — then rely on maxTotalBytes for the absolute bound, because the ratio is only ever a heuristic.

EEXIST: file already exists, open '/scratch/job-91/config' — the wx flag firing on a duplicate entry name. Some legitimate build tools emit duplicates. If you must tolerate them, de-duplicate in the preflight loop by keeping the last occurrence, and never resolve it by switching the flag to w.

The abort still costs you one chunk. zlib pushes 16 KiB at a time, so the meter always overshoots by up to one chunk before it fires — the numbers in your logs land on 16 KiB boundaries. That is fine for a budget and fatal for a hard quota, so keep the filesystem quota well above maxTotalBytes and size the worker’s heap for concurrency, not for one job: eight parallel extractions each holding a 16 KiB chunk is trivial, eight each holding a buffered entry is not. Stream to disk, never to a Buffer, using the same discipline as streaming file uploads in Node.js with Web Streams.

Verification

Build a real bomb — one gibibyte of zeros compresses to roughly a mebibyte — and prove both gates independently.

head -c 1073741824 /dev/zero > zeros.bin
zip -9 bomb.zip zeros.bin        # ≈1.0 MiB, ratio ≈1030:1
# Forge the central directory: offset 24 of the record is uncompressedSize.
node --input-type=module -e '
import { readFile, writeFile } from "node:fs/promises";
const buf = await readFile("bomb.zip");
const cd = buf.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
buf.writeUInt32LE(1048576, cd + 24);
await writeFile("forged.zip", buf);
'
import { extractGuarded, ArchiveRejected } from "./guarded-unzip.js";

for (const file of ["bomb.zip", "forged.zip"]) {
  try {
    const result = await extractGuarded(file, `/scratch/${file}.out`);
    console.error(`FAIL ${file} extracted ${result.bytes} bytes`);
  } catch (err) {
    if (!(err instanceof ArchiveRejected)) throw err;
    console.log(`PASS ${file} -> ${err.code}: ${err.message}`);
  }
}
PASS bomb.zip -> expansion_ratio: archive declares 1073741824 bytes from 1042251 on disk (1030.2:1), over maxRatio=120 / maxTotalBytes=536870912
PASS forged.zip -> size_mismatch: entry "zeros.bin" declared 1048576 bytes but has already produced 1064960; central directory is forged

The second line is the one to keep in a regression test. It proves the extractor stopped 1 GiB short of the truth, at 1 064 960 bytes — the declared 1 048 576 plus a single 16 KiB chunk — because it treated the declaration as a limit rather than as information. Add a third case with a nested archive to exercise the shared budget, and one entry named ../../etc/passwd to exercise entry_path; zip will not create that for you, so write the name into a fixture archive committed to the repository.

Also confirm the destination directory is gone after each rejection. A leftover partial tree is how a rejected archive still reaches downstream processing, and it is the failure that survives longest in production because nothing logs it.

Frequently Asked Questions

Does a virus scanner catch zip bombs?

Only known ones. ClamAV ships signatures for 42.zip and its relatives, and MaxScanSize/MaxRecursion in clamd.conf will make it give up on deeply nested archives — but a bomb generated an hour ago matches no signature, and giving up is not the same as rejecting. Run both: structural limits decide whether to extract, and the scanner inspects what came out. The quarantine bucket pattern is the right home for anything either check rejects.

Do gzip and tar need the same treatment?

Yes, and gzip is worse: a .gz member records its uncompressed size in only four bytes, modulo 2³², so a 6 GiB payload declares 1.7 GiB and you cannot preflight at all. For gzip and tar there is no central directory to read, so the byte meter is your only gate — wrap the decompress stream in the same BudgetMeter and set the cap from policy rather than from the file. Node’s zlib also accepts a maxOutputLength option, which fails with ERR_BUFFER_TOO_LARGE on the one-shot convenience methods.

Should I check the ratio per entry or across the archive?

Both, and they catch different things. The archive-wide ratio catches many small hyper-compressible entries that individually look ordinary. The per-entry maxEntryBytes check catches the single 4 GiB member that would fill the disk even though the archive-wide ratio is unremarkable. The per-entry ratio specifically is the least useful of the three, because DEFLATE’s 1032:1 ceiling makes it a narrow band.

Can I run these checks on an object in S3 without downloading it?

Partly. Fetch the last 64 KiB with Range: bytes=-65536 to locate the EOCD, then a second ranged GET for the central directory itself, and you can run Gate 1 for the price of two requests and a few kilobytes. That is enough to reject most bombs before any transfer, and it composes with the URL issuance described in generating secure presigned URLs with AWS SDK v3. Gate 2 still requires the bytes.

What should the API return when an archive is rejected?

422 Unprocessable Entity with the code and nothing else — never the declared sizes or the offending entry name. Those numbers tell an attacker exactly where your limits sit and let them binary-search for a payload that fits underneath. Keep the full detail in your logs, keyed by an incident id you also give the client.