mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🛡️ fix: Reject zip-bomb office files before in-process parsing (SEC)
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.
This commit is contained in:
parent
b41055b5ba
commit
ab71b322d3
6 changed files with 415 additions and 1 deletions
|
|
@ -346,5 +346,30 @@ describe('extractCodeArtifactText', () => {
|
|||
expect(mockOfficeHtml).toHaveBeenCalledWith(expect.any(Buffer), name, mime);
|
||||
expect(text).toContain('<body>');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
|
||||
|
|
@ -93,8 +94,14 @@ async function pdfToText(file: Express.Multer.File): Promise<string> {
|
|||
|
||||
/** Parses Word document, returns text inside. */
|
||||
async function wordDocToText(file: Express.Multer.File): Promise<string> {
|
||||
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<string> {
|
|||
// 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 = '';
|
||||
|
|
|
|||
|
|
@ -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<Buffer> => {
|
||||
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 <script> tags entirely', async () => {
|
||||
const out = await sanitizeOfficeHtml('<p>before<script>alert(1)</script>after</p>');
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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 `<table>` 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<string> {
|
||||
/* 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<string> {
|
||||
await assertSafeZipSize(buffer, { name: 'pptx' });
|
||||
const rawSlides = await extractPptxSlideXml(buffer);
|
||||
const slides: PptxSlide[] = rawSlides.map(({ number, xml }) => {
|
||||
const { title, body } = extractSlideText(xml);
|
||||
|
|
|
|||
124
packages/api/src/files/documents/zipSafety.spec.ts
Normal file
124
packages/api/src/files/documents/zipSafety.spec.ts
Normal file
|
|
@ -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<Buffer> => {
|
||||
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<Buffer> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
165
packages/api/src/files/documents/zipSafety.ts
Normal file
165
packages/api/src/files/documents/zipSafety.ts
Normal file
|
|
@ -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<void> {
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue