🛡️ 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:
Danny Avila 2026-05-04 07:03:05 +09:00
parent b41055b5ba
commit ab71b322d3
6 changed files with 415 additions and 1 deletions

View file

@ -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);
});
});
});

View file

@ -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 = '';

View file

@ -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>');

View file

@ -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);

View 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);
});
});

View 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));
});
});
}