mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
e16f4038ed
commit
165a14afda
1 file changed
+46
-2
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user