import { crc32, inflateRawSync } from 'node:zlib'; import AdmZip from 'adm-zip'; import { cleanSourcePath } from './cert-names'; import type { IgnoredEntry } from './cert-types'; /** * ZIP-Erkennung und -Entpacken des Zertifikat-Managers (quick-261009-ikt, D-17). * * Regeln: * - Ein ZIP erkennt man an den Anfangsbytes (PK\x03\x04 oder PK\x05\x06), nie an der Endung. * - Nur eine Ebene: ein Eintrag, der selbst ein ZIP ist, wird mit Grund `nestedZip` gemeldet. * - Muell wird still uebersprungen: Ordner, `__MACOSX/`, Namen mit fuehrendem Punkt, Thumbs.db, * desktop.ini. Er zaehlt auch nicht zur Eintragsgrenze. * - Mehr als `maxEntries` Eintraege: das ganze ZIP wird mit `tooManyEntries` abgelehnt. * - Verschluesselter Eintrag (Flag Bit 0): `encryptedZip`. Deklarierte Groesse ueber `maxEntryBytes`: * `tooLarge`. Deklarierte Groesse / max(gepackt, 1) ueber `maxRatio`: `suspicious`. * - Summe der deklarierten Groessen der behaltenen Eintraege ueber `maxTotalBytes`: das ganze ZIP * wird mit `zipTooLarge` abgelehnt. * - Die Kopfpruefungen laufen VOR dem ersten Entpacken, vertrauen aber nur ehrlichen Kopfdaten. * Die deklarierte Groesse im Zentralverzeichnis ist Angreiferwunsch (adm-zip begrenzt bei * Groesse 0 gar nicht, Review CR-01). Deshalb entpackt dieses Modul selbst, mit hartem Deckel * (`maxOutputLength` = Einzelgrenze, hoechstens die Restgrenze der Summe), und prueft Groesse, * Verhaeltnis und CRC am tatsaechlichen Ergebnis. Ein Eintrag, dessen echte Groesse von der * deklarierten abweicht, gilt als `suspicious`. Wird die Summe der echten Groessen ueberschritten, * wird das ganze ZIP mit `zipTooLarge` abgelehnt. * - Eintragsnamen dienen nur der Anzeige (Steuerzeichen entfernt, hoechstens 255 Zeichen) und * werden nie als Dateipfad benutzt. */ export interface ZipLimits { maxEntries: number; maxEntryBytes: number; maxRatio: number; maxTotalBytes: number; } export const ZIP_LIMITS: ZipLimits = { maxEntries: 100, maxEntryBytes: 1024 * 1024, maxRatio: 100, maxTotalBytes: 20 * 1024 * 1024, }; export interface ZipBlob { /** Anzeigepfad: "zipname/eintrag" */ path: string; buffer: Buffer; } export interface ZipExpansion { blobs: ZipBlob[]; ignored: IgnoredEntry[]; } /** ZIP-Anfangsbytes: lokaler Dateikopf (PK 03 04) oder leeres Archiv (PK 05 06). */ export function isZip(buffer: Buffer): boolean { return ( buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b && ((buffer[2] === 0x03 && buffer[3] === 0x04) || (buffer[2] === 0x05 && buffer[3] === 0x06)) ); } const JUNK_FILES = new Set(['thumbs.db', 'desktop.ini']); function isJunk(entryName: string, isDirectory: boolean): boolean { if (isDirectory) return true; const segments = entryName.split(/[\\/]/).filter(Boolean); if (segments.length === 0) return true; if (segments.some((s) => s === '__MACOSX' || s.startsWith('.'))) return true; return JUNK_FILES.has(segments[segments.length - 1].toLowerCase()); } function displayPath(zipName: string, entryName: string): string { return cleanSourcePath(`${zipName}/${entryName.replace(/^[\\/]+/, '')}`); } class EntryTooLargeError extends Error {} const STORED = 0; const DEFLATED = 8; /** * Entpackt einen Eintrag mit hartem Deckel auf die echte Ausgabe (nicht auf die deklarierte * Groesse). STORED-Eintraege werden vor dem Kopieren gegen den Deckel gemessen, DEFLATE-Daten * laufen mit `maxOutputLength` durch zlib. Die CRC kommt aus dem Zentralverzeichnis. */ function inflateEntry(entry: AdmZip.IZipEntry, cap: number): Buffer { const packed = entry.getCompressedData(); const method = entry.header.method; let data: Buffer; if (method === STORED) { if (packed.length > cap) throw new EntryTooLargeError(); data = Buffer.from(packed); } else if (method === DEFLATED) { try { data = inflateRawSync(packed, { maxOutputLength: cap }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE') { throw new EntryTooLargeError(); } throw error; } } else { throw new Error('unsupported method'); } if (crc32(data) !== entry.header.crc) throw new Error('bad crc'); return data; } export function expandZip( buffer: Buffer, zipName: string, file: number, limits: ZipLimits = ZIP_LIMITS, ): ZipExpansion { const blobs: ZipBlob[] = []; const ignored: IgnoredEntry[] = []; const whole = (reason: IgnoredEntry['reason']): ZipExpansion => ({ blobs: [], ignored: [{ file, path: cleanSourcePath(zipName), reason }], }); let entries: ReturnType; try { entries = new AdmZip(buffer).getEntries(); } catch { return whole('brokenZip'); } const candidates = entries.filter((e) => !isJunk(e.entryName, e.isDirectory)); if (candidates.length > limits.maxEntries) return whole('tooManyEntries'); // Kopfdaten pruefen, bevor irgendein Eintrag entpackt wird. const kept: typeof candidates = []; let total = 0; for (const entry of candidates) { const path = displayPath(zipName, entry.entryName); const size = entry.header.size; if (entry.header.encrypted) { ignored.push({ file, path, reason: 'encryptedZip' }); } else if (entry.entryName.toLowerCase().endsWith('.zip')) { ignored.push({ file, path, reason: 'nestedZip' }); } else if (size > limits.maxEntryBytes) { ignored.push({ file, path, reason: 'tooLarge' }); } else if (size / Math.max(entry.header.compressedSize, 1) > limits.maxRatio) { ignored.push({ file, path, reason: 'suspicious' }); } else { kept.push(entry); total += size; } } if (total > limits.maxTotalBytes) return whole('zipTooLarge'); let actualTotal = 0; for (const entry of kept) { const path = displayPath(zipName, entry.entryName); // Harter Deckel: nie mehr als die Einzelgrenze und nie mehr als die Restgrenze der Summe. const remaining = limits.maxTotalBytes - actualTotal; const cap = Math.min(limits.maxEntryBytes, remaining); let data: Buffer; try { data = inflateEntry(entry, cap); } catch (error) { if (error instanceof EntryTooLargeError) { // Die Restgrenze der Summe war enger als die Einzelgrenze: das ganze ZIP ist zu gross. if (cap < limits.maxEntryBytes) return whole('zipTooLarge'); ignored.push({ file, path, reason: 'tooLarge' }); } else { ignored.push({ file, path, reason: 'brokenZip' }); } continue; } actualTotal += data.length; if (actualTotal > limits.maxTotalBytes) return whole('zipTooLarge'); const declared = entry.header.size; if ( data.length !== declared || data.length / Math.max(entry.header.compressedSize, 1) > limits.maxRatio ) { ignored.push({ file, path, reason: 'suspicious' }); continue; } if (isZip(data)) { ignored.push({ file, path, reason: 'nestedZip' }); } else { blobs.push({ path, buffer: data }); } } return { blobs, ignored }; }