fix(import): refuse zip-bomb expansions on the Apple Health export

The hand-rolled extractor called `inflateRawSync(compressed)` without
a `maxOutputLength` option and never inspected the central directory's
advertised uncompressed size, so a 1 KB ZIP could expand to tens of
gigabytes during inflate and OOM the shared Next.js process. The route
already caps the upload at 1.5 GB and rate-limits to 3 / min per user,
but a single successful inflate would still take the whole container
with it.

Two layers of defence land here:

- Pre-flight refusal on the central directory's `uncompressedSize`
  when it exceeds 8 GiB or when the advertised compression ratio
  exceeds 200×. This is a coarse signal — the metadata is attacker-
  controlled — but it catches honest-but-oversized payloads early.
- `inflateRawSync(compressed, { maxOutputLength: 8 GiB })` instructs
  `node:zlib` to throw rather than expand past the cap, which holds
  even when the metadata lies about the uncompressed size.

8 GiB leaves a wide ceiling for legitimate multi-year heavy-user
exports (the largest real-world ones settle in the low single-digit
GB range), while making the bomb class infeasible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-27 00:08:45 +02:00
1 parent e16f4038ed
commit 165a14afda
1 file changed
+46 -2
+46 -2
View File
@@ -32,6 +32,23 @@ const ZIP64_EOCD_RECORD = 0x06064b50;
const CENTRAL_FILE_HEADER = 0x02014b50;
const LOCAL_FILE_HEADER = 0x04034b50;
/**
* Hard cap on the decompressed `export.xml` size. Apple Health exports
* for heavy multi-year accounts settle in the low single-digit GB
* range; 8 GiB leaves a wide ceiling for any legitimate user while
* making zip-bomb expansion (1000:1 deflate ratios are easily
* crafted) refuse the inflate before it OOMs the process.
*/
const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024 * 1024;
/**
* Pre-flight refusal threshold for the central-directory's advertised
* ratio. Legitimate Apple Health XML compresses at maybe 10–20× under
* DEFLATE; anything claiming a 200× expansion is a synthesized bomb.
* This is a coarse signal — the `maxOutputLength` ceiling on the
* inflate call below is the load-bearing defence.
*/
const MAX_COMPRESSION_RATIO = 200;
/** A single entry within the archive's central directory. */
interface CentralDirectoryEntry {
fileName: string;
@@ -86,6 +103,30 @@ export function extractExportXml(archivePath: string): UnzipResult {
);
}
// Pre-flight zip-bomb defence. The central directory's advertised
// `uncompressedSize` is attacker-controlled (a malicious archive can
// lie), so this catches honest-but-oversized payloads early; the
// load-bearing defence is the `maxOutputLength` cap inside
// `extractEntry()` which trips on the actual inflate output.
if (exportXmlEntry.uncompressedSize > MAX_DECOMPRESSED_BYTES) {
throw new Error(
`export.xml declares an uncompressed size of ${exportXmlEntry.uncompressedSize} bytes`
+ ` — refusing to extract (cap is ${MAX_DECOMPRESSED_BYTES} bytes).`,
);
}
if (
exportXmlEntry.compressedSize > 0
&& exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize
> MAX_COMPRESSION_RATIO
) {
throw new Error(
`export.xml advertises a ${(
exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize
).toFixed(0)}× compression ratio (cap is ${MAX_COMPRESSION_RATIO}×)`
+ " — refusing as a suspected zip bomb.",
);
}
const xmlBytes = extractEntry(buf, exportXmlEntry);
const xmlPath = join(
@@ -236,8 +277,11 @@ function extractEntry(buf: Buffer, entry: CentralDirectoryEntry): Buffer {
if (entry.compressionMethod === 0) {
return compressed;
}
// Method 8 — DEFLATE.
return inflateRawSync(compressed);
// Method 8 — DEFLATE. `maxOutputLength` instructs `node:zlib` to
// refuse expansion past the cap with a thrown error, defeating
// zip-bomb amplification even when the central-directory metadata
// lies about the uncompressed size.
return inflateRawSync(compressed, { maxOutputLength: MAX_DECOMPRESSED_BYTES });
}
/**