Search docs

Jump between documentation pages.

Browse docs

Inbound request-decompression bomb guard

DaloyJS core deliberately does not decompress request bodies — it is safe by omission. A Content-Encoding: gzip request body is read as-is, and a schema parse simply fails on the compressed bytes. The moment you inflate attacker-supplied bytes, though, you inherit the classic decompression bomb(a.k.a. "zip bomb"): a few kilobytes of crafted gzip can expand to gigabytes and blow straight past bodyLimitBytes, which only ever sees the small compressed payload.

As of 0.37.0 DaloyJS ships requestDecompression() — the opt-in middleware that adds request decompression with the bomb guard baked in. It inflates the body with two independent caps enforced during inflation, so a bomb is aborted long before it is fully materialised in memory.

Quick start

ts
import { App, requestDecompression } from "@daloyjs/core";

const app = new App();

// Register globally so it runs before schema-body validation.
app.use(requestDecompression({
  maxDecompressedBytes: 1024 * 1024, // inflated body never exceeds 1 MiB
  maxCompressedBytes: 64 * 1024,     // reject compressed uploads over 64 KiB
  maxRatio: 50,                      // and never expand more than 50x
}));

The middleware runs in the onRequestphase — before the per-request context (and therefore before schema-body validation) is built — and stashes the inflated bytes on the request so the framework's own body reader transparently sees the decompressed payload. That means it works for both schema-validated bodies and handlers that read the raw body themselves.

The two caps

A single absolute byte cap is not enough on its own: a small payload can stay under it yet still amplify wildly. requestDecompression() enforces both, during inflation:

  • maxDecompressedBytes (required) — the inflated body may never exceed this many bytes. Inflation aborts the moment output crosses this value. There is no "unlimited" mode. Set this at or below your bodyLimitBytes so the inflated payload still fits the body the rest of the app expects.
  • maxRatio (default 100) — the inflated size may never exceed compressedBytes * maxRatio, catching small-but-explosive payloads that would stay under the absolute cap in isolation.

Crossing either cap aborts inflation and rejects the request with 413 Payload Too Large (a DecompressionBombError) — the reader is cancelled mid-stream, so the full bomb is never materialised.

Bounding the compressed input

maxCompressedBytes (default 1048576 / 1 MiB) caps the compressed upload before a single byte is inflated. An oversized compressed body is rejected with 413 without inflating anything.

Supported encodings

Built on the web-standard DecompressionStream, so the same line works on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge. Only gzip and deflate are accepted (the encodings DecompressionStream implements consistently across runtimes). Brotli is intentionally excluded — it is not part of the Compression Streams spec and is unavailable on most runtimes. Restrict the allowlist with encodings:

ts
app.use(requestDecompression({
  maxDecompressedBytes: 512 * 1024,
  encodings: ["gzip"], // accept gzip only; deflate uploads get a 415
}));

Error responses

  • 413 — a decompression bomb tripped either cap, or the compressed upload exceeded maxCompressedBytes.
  • 415 — an unknown, non-allowlisted, runtime-unsupported, or layered (gzip, gzip) encoding. Layered encodings are a classic nested-bomb vector and are refused rather than inflated recursively. The response carries an Accept-Encoding header listing the allowed encodings.
  • 400 — a malformed / truncated compressed stream. Refusing (rather than treating a malformed body as empty) prevents request-smuggling-style desync with any downstream parser.

Requests without a Content-Encoding (or with identity) pass through untouched, and GET / HEAD requests are never decompressed — so bodyless and uncompressed traffic pays nothing.

Observability

Pass onBomb to record rejected bombs (it fires before the 413 is thrown). It receives the structured DecompressionBombInfo — the encoding, compressed size, inflated bytes produced before the abort, and which cap tripped ("absolute" or "ratio"):

ts
app.use(requestDecompression({
  maxDecompressedBytes: 1024 * 1024,
  onBomb: (info) => {
    metrics.increment("request.decompression.bomb", {
      encoding: info.encoding,
      reason: info.reason,
    });
  },
}));

Low-level helper

decompressRequestBody(compressed, encoding, opts) is exported for custom flows that read raw bytes themselves — it inflates with the exact same bomb-resistant semantics and the same caps.

Relationship to bodyLimitBytes

bodyLimitBytes caps the body the app reads — which, with this guard installed, is the inflated payload. Keep maxDecompressedBytes at or below bodyLimitBytes so a request that survives the bomb guard still fits the limit the rest of the stack assumes. Without this middleware, bodyLimitBytes only ever sees the compressed bytes — which is exactly why an unguarded decompression step is dangerous.