# 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 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.

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.

**Diagram: onRequest decompression**

1. **Compressed body** (inbound) - Content-Encoding: gzip
2. **Bound compressed input** - maxCompressedBytes
3. **Inflate with caps** - maxDecompressedBytes / maxRatio
4. **Inflated body to app** - fits bodyLimitBytes
5. **Bomb tripped a cap** - 413 DecompressionBombError

The middleware caps the compressed upload first, then inflates with two caps enforced during inflation. Either cap crossing cancels the stream mid-inflate and rejects with 413, so the full bomb is never materialised.

## 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 `onRequest` phase, 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, and Cloudflare Workers. 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.

---

Source: https://daloyjs.dev/docs/request-decompression