From 04110884b4e90a43cc5b337e063d0b7493be5da7 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:45:38 +0100 Subject: [PATCH] better zip handling --- apps/server/package.json | 2 +- .../integrations/import/utils/file.utils.ts | 60 +++++++++++++++---- pnpm-lock.yaml | 16 ++--- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index f10dadc07..e51759020 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -122,7 +122,7 @@ "typesense": "3.0.5", "undici": "7.28.0", "ws": "8.21.0", - "yauzl": "3.2.1", + "yauzl": "3.4.0", "zod": "4.3.6" }, "devDependencies": { diff --git a/apps/server/src/integrations/import/utils/file.utils.ts b/apps/server/src/integrations/import/utils/file.utils.ts index 0b27554be..7a0a4ff75 100644 --- a/apps/server/src/integrations/import/utils/file.utils.ts +++ b/apps/server/src/integrations/import/utils/file.utils.ts @@ -31,31 +31,39 @@ export function getFileTaskFolderPath( } } -/** - * Extracts a ZIP archive. - */ +const COMPRESSION_HEADROOM = 10; +const MIN_EXTRACTED_BYTES = 256 * 1024 * 1024; +const MAX_ENTRIES = 250_000; + +type SizeBudget = { used: number; max: number }; + export async function extractZip( source: string, target: string, ): Promise { - return extractZipInternal(source, target, true); + const { size: compressedSize } = await fs.promises.stat(source); + const max = Math.max( + compressedSize * COMPRESSION_HEADROOM, + MIN_EXTRACTED_BYTES, + ); + return extractZipInternal(source, target, true, { used: 0, max }); } -/** - * Internal helper to extract a ZIP, with optional single-nested-ZIP handling. - * @param source Path to the ZIP file - * @param target Directory to extract into - * @param allowNested Whether to check and unwrap one level of nested ZIP - */ function extractZipInternal( source: string, target: string, allowNested: boolean, + budget: SizeBudget, ): Promise { return new Promise((resolve, reject) => { yauzl.open( source, - { lazyEntries: true, decodeStrings: false, autoClose: true }, + { + lazyEntries: true, + decodeStrings: false, + autoClose: true, + validateEntrySizes: true, + }, (err, zipfile) => { if (err) return reject(err); @@ -73,6 +81,15 @@ function extractZipInternal( ? source.slice(0, -4) + '.inner.zip' : source + '.inner.zip'; + budget.used += entry.uncompressedSize; + if (budget.used > budget.max) { + return reject( + new Error( + 'Import archive exceeds the allowed extracted size limit', + ), + ); + } + zipfile.openReadStream(entry, (openErr, rs) => { if (openErr) return reject(openErr); const ws = fs.createWriteStream(nestedPath); @@ -80,7 +97,7 @@ function extractZipInternal( ws.on('error', reject); ws.on('finish', () => { zipfile.close(); - extractZipInternal(nestedPath, target, false) + extractZipInternal(nestedPath, target, false, budget) .then(() => { fs.unlinkSync(nestedPath); resolve(); @@ -91,13 +108,21 @@ function extractZipInternal( }); } else { zipfile.close(); - extractZipInternal(source, target, false).then(resolve, reject); + extractZipInternal(source, target, false, budget).then( + resolve, + reject, + ); } }); zipfile.once('error', reject); return; } + if (zipfile.entryCount > MAX_ENTRIES) { + zipfile.close(); + return reject(new Error('Import archive has too many entries')); + } + // Normal extraction zipfile.readEntry(); zipfile.on('entry', (entry) => { @@ -143,6 +168,15 @@ function extractZipInternal( return; } + budget.used += entry.uncompressedSize; + if (budget.used > budget.max) { + return reject( + new Error( + 'Import archive exceeds the allowed extracted size limit', + ), + ); + } + // Handle files try { fs.mkdirSync(path.dirname(fullPath), { recursive: true }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71f2e4f49..5495d9503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -772,8 +772,8 @@ importers: specifier: 8.21.0 version: 8.21.0 yauzl: - specifier: 3.2.1 - version: 3.2.1 + specifier: 3.4.0 + version: 3.4.0 zod: specifier: 4.3.6 version: 4.3.6 @@ -5606,9 +5606,6 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -10305,8 +10302,8 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@3.2.1: - resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} yjs@13.6.30: @@ -15833,8 +15830,6 @@ snapshots: dependencies: node-int64: 0.4.0 - buffer-crc32@0.2.13: {} - buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -21154,9 +21149,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@3.2.1: + yauzl@3.4.0: dependencies: - buffer-crc32: 0.2.13 pend: 1.2.0 yjs@13.6.30: