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