import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import yauzl from 'yauzl'; const MAX_ENTRIES = 50_000; const MAX_FILE_SIZE = 256 * 1024 * 1024; const MAX_TOTAL_SIZE = 2 * 1024 * 1024 * 1024; const MAX_MANIFEST_SIZE = 1024 * 1024; const MAX_COMPRESSION_RATIO = 1_000; function openZip(file) { return new Promise((resolve, reject) => { yauzl.open( file, { lazyEntries: true, autoClose: true, decodeStrings: true, validateEntrySizes: true, }, (error, zip) => { if (error) reject(error); else resolve(zip); } ); }); } function readEntry(zip, entry, limit = MAX_FILE_SIZE) { return new Promise((resolve, reject) => { zip.openReadStream(entry, (error, stream) => { if (error) return reject(error); const chunks = []; let length = 0; stream.on('data', (chunk) => { length += chunk.length; if (length > limit) stream.destroy( new Error(`ZIP entry exceeds the ${limit}-byte read limit.`) ); else chunks.push(chunk); }); stream.once('error', reject); stream.once('end', () => resolve(Buffer.concat(chunks))); }); }); } function unixFileType(entry) { const madeBy = entry.versionMadeBy >>> 8; return madeBy === 3 ? (entry.externalFileAttributes >>> 16) & 0o170000 : 0; } export function safeEntryPath(fileName) { if ( typeof fileName !== 'string' || fileName.length === 0 || fileName.includes('\0') ) throw new Error('ZIP contains an invalid empty or NUL path.'); const portable = fileName.replaceAll('\\', '/'); if (portable.startsWith('/') || /^[A-Za-z]:\//.test(portable)) throw new Error(`ZIP contains an absolute path: ${fileName}`); const components = portable.split('/'); if (components.some((part) => part === '..')) throw new Error(`ZIP path traversal blocked: ${fileName}`); const normalized = path.posix.normalize(portable); if (normalized === '..' || normalized.startsWith('../')) throw new Error(`ZIP path traversal blocked: ${fileName}`); if (normalized === '.' && !portable.endsWith('/')) throw new Error(`ZIP contains an invalid path: ${fileName}`); return normalized; } function validateMetadata(entry, total) { const normalized = safeEntryPath(entry.fileName); const directory = entry.fileName.endsWith('/'); const fileType = unixFileType(entry); if (fileType === 0o120000) throw new Error(`ZIP symbolic link blocked: ${entry.fileName}`); if (fileType !== 0 && fileType !== 0o100000 && fileType !== 0o040000) throw new Error(`ZIP special file blocked: ${entry.fileName}`); if (entry.uncompressedSize > MAX_FILE_SIZE) throw new Error(`ZIP entry is too large: ${entry.fileName}`); if ( entry.compressedSize > 0 && entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO ) { throw new Error( `ZIP entry has an unsafe compression ratio: ${entry.fileName}` ); } if (total + entry.uncompressedSize > MAX_TOTAL_SIZE) throw new Error('ZIP exceeds the total uncompressed-size limit.'); return { normalized, directory }; } export async function inspectAppArchive(file) { const zip = await openZip(file); const entries = []; const paths = new Set(); let total = 0; let manifestBuffer; await new Promise((resolve, reject) => { let settled = false; const fail = (error) => { if (settled) return; settled = true; zip.close(); reject(error); }; zip.once('error', fail); zip.once('end', () => { if (settled) return; settled = true; resolve(); }); zip.on('entry', (entry) => { void (async () => { if (entries.length >= MAX_ENTRIES) throw new Error(`ZIP exceeds the ${MAX_ENTRIES}-entry limit.`); const metadata = validateMetadata(entry, total); total += entry.uncompressedSize; if (paths.has(metadata.normalized)) throw new Error(`ZIP contains a duplicate path: ${entry.fileName}`); paths.add(metadata.normalized); entries.push({ fileName: entry.fileName, ...metadata }); if (!metadata.directory && metadata.normalized === 'toolbox-app.json') { manifestBuffer = await readEntry(zip, entry, MAX_MANIFEST_SIZE); } zip.readEntry(); })().catch(fail); }); zip.readEntry(); }); if (!manifestBuffer) throw new Error('ZIP does not contain toolbox-app.json at its root.'); let manifest; try { manifest = JSON.parse(manifestBuffer.toString('utf8')); } catch (error) { throw new Error('ZIP toolbox-app.json is not valid JSON.', { cause: error, }); } return { entries, manifest }; } export async function extractInspectedArchive(file, destination, inspection) { const root = path.resolve(destination); await mkdir(root, { recursive: true }); const allowed = new Map( inspection.entries.map((entry) => [entry.fileName, entry]) ); const zip = await openZip(file); await new Promise((resolve, reject) => { let settled = false; const fail = (error) => { if (settled) return; settled = true; zip.close(); reject(error); }; zip.once('error', fail); zip.once('end', () => { if (settled) return; settled = true; resolve(); }); zip.on('entry', (entry) => { void (async () => { const metadata = allowed.get(entry.fileName); if (!metadata) throw new Error( `ZIP changed between inspection and extraction: ${entry.fileName}` ); const target = path.resolve(root, ...metadata.normalized.split('/')); if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error( `ZIP path escaped extraction root: ${entry.fileName}` ); if (metadata.directory) { await mkdir(target, { recursive: true }); } else { await mkdir(path.dirname(target), { recursive: true }); await new Promise((resolveStream, rejectStream) => { zip.openReadStream(entry, (error, stream) => { if (error) return rejectStream(error); pipeline( stream, createWriteStream(target, { flags: 'wx', mode: 0o644 }) ).then(resolveStream, rejectStream); }); }); } zip.readEntry(); })().catch(fail); }); zip.readEntry(); }); }