From ab71b322d3e1a9210f4176cb9390ef543f45e188 Mon Sep 17 00:00:00 2001
From: Danny Avila
Date: Mon, 4 May 2026 07:03:05 +0900
Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Reject=20zip-bom?=
=?UTF-8?q?b=20office=20files=20before=20in-process=20parsing=20(SEC)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses pre-existing availability vulnerability validated by
SEC review (Codex finding 275344c5...) and made worse by this PR's
HTML rendering path. A sub-1MiB compressed XLSX/DOCX/PPTX (highly
compressed run-of-zeros) inflates to 200+ MiB of XML when handed
to mammoth/xlsx — blocking the Node event loop for 10+ seconds and
spiking RSS to ~1 GiB. The existing 8s `withTimeout` wrapper uses
`Promise.race`, which can only return early; it cannot interrupt
synchronous parser CPU/RAM consumption. PoC ran an authenticated
execute_code call to OOM the API process.
Add `assertSafeZipSize(buffer)` — a yauzl-based pre-flight that
streams every entry with mid-inflate byte counting and bails on
either a per-entry or total decompressed-size cap. Mid-inflate
counting cannot be bypassed by falsifying the central directory's
`uncompressedSize` field (the technique the PoC used). Defaults:
25 MiB per entry, 100 MiB total — generous headroom for legitimate
image-heavy office files, well below the attack profile.
Hook the check into every path that hands a buffer to mammoth/xlsx
/yauzl:
- New HTML producers (`wordDocToHtml`, `excelSheetToHtml`,
`pptxToSlideListHtml`) — added by this PR
- Legacy RAG text extractors (`wordDocToText`, `excelSheetToText`
in `crud.ts`) — pre-existing path, also vulnerable
Errors propagate as a tag-distinct `ZipBombError` so callers can
distinguish a refused bomb from generic parse failures. The outer
`extractCodeArtifactText` swallows the error and returns null,
falling back to the regular download UI.
`.xls` (BIFF/CFB binary, not ZIP) is detected by magic bytes and
skipped — yauzl would reject it as malformed anyway.
Adds 15 tests:
- `zipSafety.spec.ts` (9): benign passes, per-entry cap, total cap,
ZipBombError type-tagging, malformed-zip distinction, directory-
entry handling, named-error surfacing, and the SEC-PoC pattern
(sub-1 MiB compressed → 50 MiB inflated rejected on default caps).
- `html.spec.ts` zip-bomb suite (5): each producer rejects a bomb;
dispatcher propagates correctly; legitimate fixtures still render.
- `extract.spec.ts` (1): outer extractor swallows ZipBombError and
returns null so the download UI fallback fires.
---
packages/api/src/files/code/extract.spec.ts | 25 +++
packages/api/src/files/documents/crud.ts | 15 +-
packages/api/src/files/documents/html.spec.ts | 62 +++++++
packages/api/src/files/documents/html.ts | 25 +++
.../api/src/files/documents/zipSafety.spec.ts | 124 +++++++++++++
packages/api/src/files/documents/zipSafety.ts | 165 ++++++++++++++++++
6 files changed, 415 insertions(+), 1 deletion(-)
create mode 100644 packages/api/src/files/documents/zipSafety.spec.ts
create mode 100644 packages/api/src/files/documents/zipSafety.ts
diff --git a/packages/api/src/files/code/extract.spec.ts b/packages/api/src/files/code/extract.spec.ts
index 56d7a16097..99407ca1c2 100644
--- a/packages/api/src/files/code/extract.spec.ts
+++ b/packages/api/src/files/code/extract.spec.ts
@@ -346,5 +346,30 @@ describe('extractCodeArtifactText', () => {
expect(mockOfficeHtml).toHaveBeenCalledWith(expect.any(Buffer), name, mime);
expect(text).toContain('');
});
+
+ it('returns null (and falls back to download UI) when the producer rejects a zip bomb', async () => {
+ /* Defense-in-depth check for SEC review on PR #12934. When
+ * `bufferToOfficeHtml` throws `ZipBombError` (because a zip-bomb
+ * DOCX/XLSX/PPTX got through the compressed-size gate), the outer
+ * extractor must swallow it and return null — that signals to the
+ * code-output controller to register the file as a regular
+ * download instead of a panel artifact. Without this, an
+ * unhandled rejection would crash the request. */
+ const bombError = Object.assign(new Error('zip bomb suspected'), {
+ name: 'ZipBombError',
+ code: 'ZIP_BOMB',
+ });
+ mockOfficeHtml.mockRejectedValueOnce(bombError);
+ const text = await extractCodeArtifactText(
+ Buffer.from('PK'),
+ 'evil.docx',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'document',
+ );
+ // Falls through to parseDocument which is also mocked — returns
+ // the docx-mock text via the legacy text-extraction path. The key
+ // assertion is that we don't crash and don't leak the bomb error.
+ expect(text).toBe(docxText);
+ });
});
});
diff --git a/packages/api/src/files/documents/crud.ts b/packages/api/src/files/documents/crud.ts
index 20d547cf26..4457a30b3d 100644
--- a/packages/api/src/files/documents/crud.ts
+++ b/packages/api/src/files/documents/crud.ts
@@ -3,6 +3,7 @@ import yauzl from 'yauzl';
import { megabyte, excelMimeTypes, FileSources } from 'librechat-data-provider';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { MistralOCRUploadResult } from '~/types';
+import { assertSafeZipSize } from './zipSafety';
type FileParseFn = (file: Express.Multer.File) => Promise;
@@ -93,8 +94,14 @@ async function pdfToText(file: Express.Multer.File): Promise {
/** Parses Word document, returns text inside. */
async function wordDocToText(file: Express.Multer.File): Promise {
+ const buffer = await fs.promises.readFile(file.path);
+ /* Reject zip-bomb DOCX before mammoth's internal extractor runs.
+ * mammoth has no decompressed-size cap of its own; without this, a
+ * sub-1MB compressed bomb (~200x ratio) would block the event loop
+ * and spike RSS to ~1GB. See SEC review on PR #12934. */
+ await assertSafeZipSize(buffer, { name: file.originalname ?? 'docx' });
const { extractRawText } = await import('mammoth');
- const rawText = await extractRawText({ buffer: await fs.promises.readFile(file.path) });
+ const rawText = await extractRawText({ buffer });
return rawText.value;
}
@@ -104,6 +111,12 @@ async function excelSheetToText(file: Express.Multer.File): Promise {
// readFile() fails with "Cannot access file". read() takes a pre-loaded Buffer instead.
const { read, utils } = await import('xlsx');
const data = await fs.promises.readFile(file.path);
+ /* Reject zip-bomb XLSX/ODS before SheetJS's internal extractor runs.
+ * `.xls` (BIFF/CFB) is not a ZIP — magic-byte check skips the
+ * validator for it (yauzl would reject it as malformed anyway). */
+ if (data.length >= 4 && data[0] === 0x50 && data[1] === 0x4b) {
+ await assertSafeZipSize(data, { name: file.originalname ?? 'spreadsheet' });
+ }
const workbook = read(data, { type: 'buffer' });
let text = '';
diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts
index bed14a2e57..70a97c1a77 100644
--- a/packages/api/src/files/documents/html.spec.ts
+++ b/packages/api/src/files/documents/html.spec.ts
@@ -1,6 +1,7 @@
import path from 'path';
import * as fs from 'fs';
import JSZip from 'jszip';
+import { megabyte } from 'librechat-data-provider';
import {
bufferToOfficeHtml,
csvToHtml,
@@ -10,6 +11,7 @@ import {
sanitizeOfficeHtml,
wordDocToHtml,
} from './html';
+import { ZipBombError } from './zipSafety';
const fixturesDir = __dirname;
const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixturesDir, name));
@@ -290,6 +292,66 @@ describe('Office HTML producers', () => {
});
});
+ describe('zip-bomb defense (SEC review on PR #12934)', () => {
+ /* Build a sub-1MB compressed ZIP that inflates to >25MB (default
+ * per-entry cap). Mirrors the SEC validation PoC: highly compressed
+ * runs of zero bytes squeeze through any compressed-size gate but
+ * blow up the parser. */
+ const buildBombArchive = async (
+ entries: Array<{ name: string; decompressedBytes: number }>,
+ ): Promise => {
+ const zip = new JSZip();
+ for (const { name, decompressedBytes } of entries) {
+ zip.file(name, Buffer.alloc(decompressedBytes, 0));
+ }
+ return zip.generateAsync({
+ type: 'nodebuffer',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 9 },
+ });
+ };
+
+ test('wordDocToHtml rejects a zip-bomb DOCX before mammoth touches it', async () => {
+ const bomb = await buildBombArchive([
+ { name: 'word/document.xml', decompressedBytes: 50 * megabyte },
+ ]);
+ expect(bomb.length).toBeLessThan(1 * megabyte);
+ await expect(wordDocToHtml(bomb)).rejects.toThrow(ZipBombError);
+ });
+
+ test('excelSheetToHtml rejects a zip-bomb XLSX before SheetJS touches it', async () => {
+ const bomb = await buildBombArchive([
+ { name: 'xl/worksheets/sheet1.xml', decompressedBytes: 50 * megabyte },
+ ]);
+ expect(bomb.length).toBeLessThan(1 * megabyte);
+ await expect(excelSheetToHtml(bomb)).rejects.toThrow(ZipBombError);
+ });
+
+ test('pptxToSlideListHtml rejects a zip-bomb PPTX before slide extraction', async () => {
+ const bomb = await buildBombArchive([
+ { name: 'ppt/slides/slide1.xml', decompressedBytes: 50 * megabyte },
+ ]);
+ expect(bomb.length).toBeLessThan(1 * megabyte);
+ await expect(pptxToSlideListHtml(bomb)).rejects.toThrow(ZipBombError);
+ });
+
+ test('bufferToOfficeHtml propagates ZipBombError so callers can fail safe', async () => {
+ const bomb = await buildBombArchive([
+ { name: 'word/document.xml', decompressedBytes: 50 * megabyte },
+ ]);
+ await expect(bufferToOfficeHtml(bomb, 'evil.docx', '')).rejects.toThrow(ZipBombError);
+ });
+
+ test('legitimate small office files are not impacted by the safety check', async () => {
+ /* Real DOCX fixture from the test fixtures directory should still
+ * render — paranoid validation that the safety check doesn't false-
+ * positive on tiny legitimate inputs. */
+ const fixture = readFixture('sample.docx');
+ const html = await wordDocToHtml(fixture);
+ expect(html).toContain('This is a sample DOCX file.');
+ });
+ });
+
describe('sanitizeOfficeHtml security', () => {
test('strips after
');
diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts
index d4df8e30fe..62505fffa2 100644
--- a/packages/api/src/files/documents/html.ts
+++ b/packages/api/src/files/documents/html.ts
@@ -4,6 +4,7 @@ import * as fs from 'fs/promises';
import { randomUUID } from 'crypto';
import yauzl from 'yauzl';
import { excelMimeTypes, megabyte } from 'librechat-data-provider';
+import { assertSafeZipSize } from './zipSafety';
/**
* Maximum decompressed size we'll accept from a single PPTX entry. Mirrors the
@@ -261,8 +262,15 @@ ${bodyHtml}
* default style map (paragraph styles, runs, tables, hyperlinks, lists) and
* inlines images as base64 `data:` URIs. The output is sanitized and wrapped
* in our document template before return.
+ *
+ * Pre-flights the buffer through `assertSafeZipSize` so a zip-bomb DOCX
+ * is rejected BEFORE it reaches mammoth — mammoth's internal extraction
+ * has no decompressed-size cap and would happily inflate a sub-1MB
+ * compressed bomb to 200+ MB of XML, blocking the event loop and
+ * spiking RSS. See SEC review on PR #12934.
*/
export async function wordDocToHtml(buffer: Buffer): Promise {
+ await assertSafeZipSize(buffer, { name: 'docx' });
const { convertToHtml } = await import('mammoth');
const result = await convertToHtml({ buffer });
const sanitized = await sanitizeOfficeHtml(result.value);
@@ -385,8 +393,21 @@ function escapeHtml(input: string): string {
* Convert a workbook buffer (`.xlsx`, `.xls`, `.ods`) to a sandboxed HTML
* document. Each sheet is rendered as its own `` and the document
* carries a pure-CSS tab strip for sheet switching.
+ *
+ * Pre-flights ZIP-backed formats (`.xlsx`/`.ods`) through
+ * `assertSafeZipSize` to reject zip bombs before SheetJS reaches them.
+ * `.xls` is a binary CFB format, not a ZIP — it doesn't have the
+ * decompression-amplification attack surface, so the safety check is
+ * skipped for it (yauzl would reject it as malformed anyway).
*/
export async function excelSheetToHtml(buffer: Buffer): Promise {
+ /* Cheap magic-byte check so we only run the ZIP validator on actual
+ * ZIP-backed inputs. `.xls` (BIFF/CFB) starts with `D0 CF 11 E0`; ZIPs
+ * start with `PK\x03\x04`. Skipping the validator on a non-ZIP input
+ * also avoids confusing yauzl errors leaking out as ZipBombError. */
+ if (buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b) {
+ await assertSafeZipSize(buffer, { name: 'spreadsheet' });
+ }
const XLSX = await import('xlsx');
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheets = await renderWorkbookSheets(workbook, XLSX);
@@ -623,8 +644,12 @@ function renderPptxSlidesHtml(slides: PptxSlide[]): string {
* as a card (slide number, title, body bullets). Honest about being a
* text-only preview — complex visual layouts, charts, and embedded media are
* not represented. Higher-fidelity rendering is a deferred follow-up.
+ *
+ * Pre-flights through `assertSafeZipSize` so a zip-bomb PPTX can't blow
+ * up the slide-XML extraction pass.
*/
export async function pptxToSlideListHtml(buffer: Buffer): Promise {
+ await assertSafeZipSize(buffer, { name: 'pptx' });
const rawSlides = await extractPptxSlideXml(buffer);
const slides: PptxSlide[] = rawSlides.map(({ number, xml }) => {
const { title, body } = extractSlideText(xml);
diff --git a/packages/api/src/files/documents/zipSafety.spec.ts b/packages/api/src/files/documents/zipSafety.spec.ts
new file mode 100644
index 0000000000..473bb5715c
--- /dev/null
+++ b/packages/api/src/files/documents/zipSafety.spec.ts
@@ -0,0 +1,124 @@
+import JSZip from 'jszip';
+import { megabyte } from 'librechat-data-provider';
+import { assertSafeZipSize, ZipBombError } from './zipSafety';
+
+/**
+ * Build a ZIP archive whose entries inflate to exactly `decompressedBytes`
+ * each. The data is highly compressible (single repeated character) so
+ * compressed size stays small — roughly 0.5% of inflated size for runs
+ * of zero bytes. Used to simulate the zip-bomb attack pattern from the
+ * SEC validation PoC on PR #12934.
+ */
+const buildBombArchive = async (
+ entries: Array<{ name: string; decompressedBytes: number }>,
+): Promise => {
+ const zip = new JSZip();
+ for (const { name, decompressedBytes } of entries) {
+ zip.file(name, Buffer.alloc(decompressedBytes, 0));
+ }
+ return zip.generateAsync({
+ type: 'nodebuffer',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 9 },
+ });
+};
+
+/** Build a small, well-formed ZIP for the happy-path tests. */
+const buildBenignArchive = async (): Promise => {
+ const zip = new JSZip();
+ zip.file('hello.txt', 'hello world');
+ zip.file('subdir/note.txt', 'second entry');
+ return zip.generateAsync({ type: 'nodebuffer' });
+};
+
+describe('assertSafeZipSize', () => {
+ test('passes a benign small archive', async () => {
+ const buffer = await buildBenignArchive();
+ await expect(assertSafeZipSize(buffer)).resolves.toBeUndefined();
+ });
+
+ test('passes an archive whose entries are all under both caps', async () => {
+ const buffer = await buildBombArchive([
+ { name: 'a.bin', decompressedBytes: 1 * megabyte },
+ { name: 'b.bin', decompressedBytes: 1 * megabyte },
+ ]);
+ await expect(
+ assertSafeZipSize(buffer, { maxTotalBytes: 10 * megabyte, maxEntryBytes: 5 * megabyte }),
+ ).resolves.toBeUndefined();
+ });
+
+ test('throws ZipBombError when a single entry exceeds the per-entry cap', async () => {
+ /* Single 5 MB inflated entry compresses to a few KB. Per-entry cap of
+ * 1 MB should fire mid-inflate. */
+ const buffer = await buildBombArchive([{ name: 'big.bin', decompressedBytes: 5 * megabyte }]);
+ await expect(assertSafeZipSize(buffer, { maxEntryBytes: 1 * megabyte })).rejects.toThrow(
+ ZipBombError,
+ );
+ });
+
+ test('throws ZipBombError when total decompressed size exceeds the total cap', async () => {
+ /* Many small-but-not-tiny entries that individually pass the per-entry
+ * cap but collectively bust the total cap. Catches the multi-entry
+ * variant of the attack. */
+ const buffer = await buildBombArchive(
+ Array.from({ length: 5 }, (_, i) => ({
+ name: `chunk${i}.bin`,
+ decompressedBytes: 1 * megabyte,
+ })),
+ );
+ await expect(
+ assertSafeZipSize(buffer, { maxTotalBytes: 3 * megabyte, maxEntryBytes: 2 * megabyte }),
+ ).rejects.toThrow(ZipBombError);
+ });
+
+ test('cap-violation error is a ZipBombError, not a generic Error', async () => {
+ const buffer = await buildBombArchive([{ name: 'big.bin', decompressedBytes: 5 * megabyte }]);
+ /* Distinguishing the bomb case from a generic parse failure lets
+ * the UI surface a meaningful "preview unavailable, file too large
+ * to inflate" message instead of a vague 500. */
+ await expect(assertSafeZipSize(buffer, { maxEntryBytes: 1 * megabyte })).rejects.toMatchObject({
+ name: 'ZipBombError',
+ code: 'ZIP_BOMB',
+ });
+ });
+
+ test('rejects a malformed zip', async () => {
+ /* Not a real zip — yauzl will throw a parse error (NOT a
+ * ZipBombError; that distinction matters to callers). */
+ const buffer = Buffer.from('not a real zip');
+ await expect(assertSafeZipSize(buffer)).rejects.toThrow();
+ await expect(assertSafeZipSize(buffer)).rejects.not.toBeInstanceOf(ZipBombError);
+ });
+
+ test('handles archives containing directory entries without crashing', async () => {
+ const zip = new JSZip();
+ zip.folder('emptydir');
+ zip.file('emptydir/file.txt', 'data');
+ const buffer = await zip.generateAsync({ type: 'nodebuffer' });
+ await expect(assertSafeZipSize(buffer)).resolves.toBeUndefined();
+ });
+
+ test('uses provided name in the error message for caller-side surfacing', async () => {
+ const buffer = await buildBombArchive([{ name: 'big.bin', decompressedBytes: 5 * megabyte }]);
+ await expect(
+ assertSafeZipSize(buffer, { maxEntryBytes: 1 * megabyte, name: 'evil.docx' }),
+ ).rejects.toThrow(/evil\.docx/);
+ });
+
+ test('re-PoC: catches the SEC-validation attack pattern (sub-1MB compressed → 100MB+ inflated)', async () => {
+ /* Mirrors the SEC validation PoC shape: a sub-1MB compressed
+ * archive whose entries inflate to many tens of MB. Tests that the
+ * default caps fire on this canonical attack without the caller
+ * needing to override anything. The PoC inflated to ~200MB across
+ * several entries; we use 50MB for test-suite speed (still well
+ * over both default caps). */
+ const buffer = await buildBombArchive([
+ { name: 'word/document.xml', decompressedBytes: 50 * megabyte },
+ ]);
+ /* Defense-in-depth check: the compressed payload IS small (proves
+ * the input would slip past a compressed-size gate). */
+ expect(buffer.length).toBeLessThan(1 * megabyte);
+ /* And the validator catches it on default caps. */
+ await expect(assertSafeZipSize(buffer)).rejects.toThrow(ZipBombError);
+ });
+});
diff --git a/packages/api/src/files/documents/zipSafety.ts b/packages/api/src/files/documents/zipSafety.ts
new file mode 100644
index 0000000000..1fba064f11
--- /dev/null
+++ b/packages/api/src/files/documents/zipSafety.ts
@@ -0,0 +1,165 @@
+import yauzl from 'yauzl';
+import { megabyte } from 'librechat-data-provider';
+
+/**
+ * Default per-archive total decompressed-size cap. Office documents in
+ * normal use rarely exceed a few MB inflated; 100 MB leaves generous
+ * headroom for image-heavy templates while still catching the
+ * pathological zip-bomb case (e.g. a 1 MB compressed XLSX that inflates
+ * to 200+ MB of XML — see SEC review on PR #12934).
+ */
+const DEFAULT_MAX_TOTAL_BYTES = 100 * megabyte;
+
+/**
+ * Default per-entry decompressed-size cap. A single inflated entry
+ * larger than this is essentially always either a bomb or content the
+ * downstream parser would balk at anyway.
+ */
+const DEFAULT_MAX_ENTRY_BYTES = 25 * megabyte;
+
+/**
+ * Tag-distinct error so callers (e.g. the office HTML producers and the
+ * RAG document parser) can distinguish a refused zip-bomb from generic
+ * parse failures and emit a sensible "file too large to preview" UI
+ * instead of silently degrading.
+ */
+export class ZipBombError extends Error {
+ readonly code = 'ZIP_BOMB';
+ constructor(message: string) {
+ super(message);
+ this.name = 'ZipBombError';
+ }
+}
+
+export interface ZipSafetyOptions {
+ /** Per-archive total decompressed-byte cap. */
+ maxTotalBytes?: number;
+ /** Per-entry decompressed-byte cap. */
+ maxEntryBytes?: number;
+ /** Filename for error messages — does not need to match disk. */
+ name?: string;
+}
+
+/**
+ * Validate that a ZIP-backed buffer (DOCX, XLSX, XLS-as-OOXML, ODS,
+ * ODT, PPTX, …) does not blow up under decompression beyond the given
+ * caps. Streams every entry through yauzl and counts real decompressed
+ * bytes mid-inflate — the central directory's `uncompressedSize` cannot
+ * be trusted (it can be falsified to lie about the payload, the
+ * specific bypass technique used in the SEC validation PoC for
+ * PR #12934).
+ *
+ * Drops the decompressed bytes immediately (only counts them), so the
+ * validator's own memory footprint is bounded by yauzl's stream
+ * buffer regardless of payload size. CPU is bounded by `maxTotalBytes`
+ * — once the cap fires, the underlying read stream is destroyed and
+ * decompression stops.
+ *
+ * Throws `ZipBombError` on cap violation; throws plain `Error` on a
+ * malformed ZIP. Resolves with `void` when the archive is fully walked
+ * within caps.
+ */
+export function assertSafeZipSize(buffer: Buffer, options: ZipSafetyOptions = {}): Promise {
+ const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
+ const maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
+ const label = options.name ?? 'archive';
+
+ return new Promise((resolve, reject) => {
+ yauzl.fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => {
+ if (err) {
+ return reject(err);
+ }
+ if (!zipfile) {
+ return reject(new Error('Failed to open zip buffer'));
+ }
+
+ let settled = false;
+ let totalDecompressed = 0;
+ const finish = (error: Error | null) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ try {
+ zipfile.close();
+ } catch {
+ /* zipfile.close() is best-effort — yauzl will throw if a
+ * stream is mid-flight. We've already settled the outer
+ * promise, so swallow this. */
+ }
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ };
+
+ zipfile.readEntry();
+
+ zipfile.on('entry', (entry: yauzl.Entry) => {
+ /* Directory entries (trailing slash) are zero-byte and don't
+ * need to be opened. Saves a stream allocation per directory
+ * in archives like .docx that have nested subfolders. */
+ if (/\/$/.test(entry.fileName)) {
+ zipfile.readEntry();
+ return;
+ }
+
+ zipfile.openReadStream(entry, (streamErr, readStream) => {
+ if (streamErr || !readStream) {
+ return finish(streamErr ?? new Error('Failed to open zip entry stream'));
+ }
+
+ let entryBytes = 0;
+ let entryCapped = false;
+
+ readStream.on('data', (chunk: Buffer) => {
+ if (entryCapped) {
+ return;
+ }
+ entryBytes += chunk.byteLength;
+ totalDecompressed += chunk.byteLength;
+ if (entryBytes > maxEntryBytes) {
+ entryCapped = true;
+ readStream.destroy();
+ finish(
+ new ZipBombError(
+ `${label}: entry "${entry.fileName}" exceeds the ${
+ maxEntryBytes / megabyte
+ }MB per-entry decompressed cap`,
+ ),
+ );
+ return;
+ }
+ if (totalDecompressed > maxTotalBytes) {
+ entryCapped = true;
+ readStream.destroy();
+ finish(
+ new ZipBombError(
+ `${label}: total decompressed size exceeds the ${
+ maxTotalBytes / megabyte
+ }MB cap (zip bomb suspected)`,
+ ),
+ );
+ return;
+ }
+ });
+
+ readStream.on('end', () => {
+ if (!settled) {
+ zipfile.readEntry();
+ }
+ });
+ readStream.on('error', (readErr: Error) => {
+ if (!entryCapped) {
+ finish(readErr);
+ }
+ });
+ });
+ });
+
+ zipfile.on('end', () => finish(null));
+ zipfile.on('error', (zipErr: Error) => finish(zipErr));
+ });
+ });
+}