ff2226dfbd
- ZIP: eigenes Entpacken mit hartem Deckel (maxOutputLength), Größe, Verhältnis und CRC am echten Ergebnis; eine gefälschte Kopfgröße 0 umgeht keine Grenze mehr (CR-01) - PEM: linearer Scanner statt Regex mit quadratischer Laufzeit, gemeinsam für Erkennung und Abruf (CR-02) - PKCS#12: PBES2/AES leitet das Passwort aus UTF-8 ab, Lesen und Schreiben, mit OpenSSL-Gegenprobe für „pässwörd“ und „pw€“ (CR-03) - Grenzen je Anfrage: höchstens 200 Zertifikate, 50 Schlüssel, 50 Anfragen (413 tooManyItems); Ableitungsrunden höchstens 1 Million je Verfahren und 6 Millionen je Anfrage (protectionTooExpensive) (WR-04, WR-05) - Upload: Gesamtgrenze beim Empfang, multer-Grenzen für Dateien, Felder, Teile (WR-06) - build-Leser hinter CORS, damit 413/400 CORS-Kopfzeilen tragen (WR-01) - Selbstsignierte Wurzeln ohne basicConstraints (Version 1, keyCertSign) sind Wurzeln, Serverzertifikate bleiben es (WR-07) - Adressschutz: IPv6-Adressen in Klammern werden beurteilt; „CA Issuers“-Filter für Anzeige und Abruf gemeinsam, eigener Code aiaNotAllowed (IN-01, IN-02) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
193 lines
6.9 KiB
TypeScript
193 lines
6.9 KiB
TypeScript
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<AdmZip['getEntries']>;
|
|
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 };
|
|
}
|