fix(cert-manager): ZIP-Bombe, PEM-Scanner und Umlaut-Passwörter in PFX behoben

- 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>
This commit is contained in:
2026-10-09 17:10:31 +02:00
parent a2fc2cb300
commit ff2226dfbd
40 changed files with 1739 additions and 151 deletions
+65 -9
View File
@@ -1,3 +1,4 @@
import { crc32, inflateRawSync } from 'node:zlib';
import AdmZip from 'adm-zip';
import { cleanSourcePath } from './cert-names';
import type { IgnoredEntry } from './cert-types';
@@ -15,9 +16,13 @@ import type { IgnoredEntry } from './cert-types';
* `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.
* - Alle diese Pruefungen laufen auf den Kopfdaten und damit VOR dem ersten Entpacken.
* adm-zip entpackt hoechstens die deklarierte Groesse und prueft die CRC; zusaetzlich wird das
* Ergebnis noch einmal gegen die Grenze geprueft.
* - 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.
*/
@@ -71,6 +76,39 @@ 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,
@@ -115,18 +153,36 @@ export function expandZip(
}
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 = entry.getData();
} catch {
ignored.push({ file, path, reason: 'brokenZip' });
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;
}
if (data.length > limits.maxEntryBytes) {
ignored.push({ file, path, reason: 'tooLarge' });
} else if (isZip(data)) {
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 });