From ff2226dfbda23d489263c3e00b28cf2b4a6c0651 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 17:10:31 +0200 Subject: [PATCH] =?UTF-8?q?fix(cert-manager):=20ZIP-Bombe,=20PEM-Scanner?= =?UTF-8?q?=20und=20Umlaut-Passw=C3=B6rter=20in=20PFX=20behoben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/cert-manager/__fixtures__/README.md | 5 +- .../__fixtures__/make-fixtures.sh | 28 +++ .../__fixtures__/rsa-euro-compat.pfx | Bin 0 -> 4429 bytes .../__fixtures__/rsa-euro-modern.pfx | Bin 0 -> 4595 bytes .../__fixtures__/rsa-expensive.pfx | Bin 0 -> 4598 bytes .../rsa-leaf-key-enc-expensive.pem | 30 ++++ .../__fixtures__/rsa-umlaut-compat.pfx | Bin 0 -> 4429 bytes .../__fixtures__/rsa-umlaut-modern.pfx | Bin 0 -> 4595 bytes .../__fixtures__/selfsigned-nobc-ca.pem | 20 +++ .../__fixtures__/selfsigned-nobc-leaf.pem | 20 +++ .../__fixtures__/selfsigned-v1-root.pem | 18 ++ .../__fixtures__/v1-root-leaf.pem | 19 +++ .../api/src/cert-manager/cert-aia-url.spec.ts | 61 +++++++ apps/api/src/cert-manager/cert-aia-url.ts | 48 ++++++ apps/api/src/cert-manager/cert-aia.spec.ts | 4 +- apps/api/src/cert-manager/cert-aia.ts | 70 ++++---- .../api/src/cert-manager/cert-analyze.spec.ts | 115 +++++++++++++ apps/api/src/cert-manager/cert-analyze.ts | 4 + apps/api/src/cert-manager/cert-budget.ts | 71 ++++++++ apps/api/src/cert-manager/cert-chain.spec.ts | 30 ++++ apps/api/src/cert-manager/cert-keys.spec.ts | 71 +++++++- apps/api/src/cert-manager/cert-keys.ts | 127 ++++++++++++-- .../cert-manager/cert-manager.changelog.ts | 4 +- .../cert-manager/cert-manager.controller.ts | 20 ++- apps/api/src/cert-manager/cert-model.spec.ts | 65 +++++++ apps/api/src/cert-manager/cert-model.ts | 144 ++++++++++++---- apps/api/src/cert-manager/cert-pkcs12.spec.ts | 159 +++++++++++++++++- apps/api/src/cert-manager/cert-pkcs12.ts | 116 +++++++++++-- apps/api/src/cert-manager/cert-types.ts | 7 +- apps/api/src/cert-manager/cert-upload.spec.ts | 80 +++++++++ apps/api/src/cert-manager/cert-upload.ts | 71 ++++++++ apps/api/src/cert-manager/pem-scan.spec.ts | 89 ++++++++++ apps/api/src/cert-manager/pem-scan.ts | 98 +++++++++++ apps/api/src/cert-manager/zip-expand.spec.ts | 72 +++++++- apps/api/src/cert-manager/zip-expand.ts | 74 +++++++- apps/api/src/common/public-url-guard.spec.ts | 18 ++ apps/api/src/common/public-url-guard.ts | 8 +- apps/api/src/http-setup.spec.ts | 43 +++++ apps/api/src/http-setup.ts | 47 ++++++ apps/api/src/main.ts | 34 +--- 40 files changed, 1739 insertions(+), 151 deletions(-) create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-euro-compat.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-euro-modern.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-expensive.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-expensive.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-umlaut-compat.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-umlaut-modern.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-ca.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-leaf.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/selfsigned-v1-root.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/v1-root-leaf.pem create mode 100644 apps/api/src/cert-manager/cert-aia-url.spec.ts create mode 100644 apps/api/src/cert-manager/cert-aia-url.ts create mode 100644 apps/api/src/cert-manager/cert-budget.ts create mode 100644 apps/api/src/cert-manager/cert-upload.spec.ts create mode 100644 apps/api/src/cert-manager/cert-upload.ts create mode 100644 apps/api/src/cert-manager/pem-scan.spec.ts create mode 100644 apps/api/src/cert-manager/pem-scan.ts create mode 100644 apps/api/src/http-setup.spec.ts create mode 100644 apps/api/src/http-setup.ts diff --git a/apps/api/src/cert-manager/__fixtures__/README.md b/apps/api/src/cert-manager/__fixtures__/README.md index b1fb2a3..e6acb99 100644 --- a/apps/api/src/cert-manager/__fixtures__/README.md +++ b/apps/api/src/cert-manager/__fixtures__/README.md @@ -6,7 +6,10 @@ Nur Testdaten (quick-261009-ikt). Keine echten Zertifikate, keine echten Schlues gleichen Namens: cross-signiert, abgelaufen, Lockvogel mit anderem Schluessel) und EC (Wurzel P-384, Zwischenzertifikat P-384, Server P-256). - Passwort aller geschuetzten Dateien (verschluesselte Schluessel, PFX, ZIP): `Test-Pass-123`. - Die Datei `rsa-nopass.pfx` hat ein leeres Passwort. + Die Datei `rsa-nopass.pfx` hat ein leeres Passwort. Ausnahmen: `rsa-umlaut-*.pfx` (Passwort `pässwörd`), + `rsa-euro-*.pfx` (Passwort `pw€`), jeweils `modern` (AES-256) und `compat` (3DES). + `rsa-expensive.pfx` und `rsa-leaf-key-enc-expensive.pem` (Passwort `Test-Pass-123`) haben 2 000 000 + Ableitungsrunden: zu aufwendig, die Grenze liegt bei 1 000 000. - Die Schluessel der CAs liegen nur waehrend der Erzeugung in einem temporaeren Ordner und werden geloescht. Committet sind ausschliesslich Schluessel der Server-Zertifikate. - Dateinamen enden nie auf `.key` (die `.gitignore` ignoriert `*.key` wegen des Updater-Schluessels). diff --git a/apps/api/src/cert-manager/__fixtures__/make-fixtures.sh b/apps/api/src/cert-manager/__fixtures__/make-fixtures.sh index 3581459..e266ecd 100644 --- a/apps/api/src/cert-manager/__fixtures__/make-fixtures.sh +++ b/apps/api/src/cert-manager/__fixtures__/make-fixtures.sh @@ -123,6 +123,21 @@ X rsa_key "$T/rsa-self2.key" selfsign "$T/rsa-self2.key" "/O=Tessera Test/CN=selfsigned.example.test" "$T/ext-self" "$OUT/selfsigned-leaf.pem" +# selbstsigniert ohne basicConstraints (Review WR-07): +# v1 (aeltere Wurzeln), v3 nur mit keyCertSign, v3 nur mit Serverschluesselverwendung +rsa_key "$T/rsa-v1.key" +printf '[req]\ndistinguished_name = dn\n[dn]\n' > "$T/v1.cnf" +openssl req -x509v1 -new -config "$T/v1.cnf" -key "$T/rsa-v1.key" -subj "/O=Tessera Test/CN=Tessera Test V1 Root" -days "$DAYS" -out "$OUT/selfsigned-v1-root.pem" +csr "$T/rsa-self2.key" "/O=Tessera Test/CN=v1-leaf.example.test" "$T/v1leaf.csr" +printf 'subjectAltName = DNS:v1-leaf.example.test\nauthorityKeyIdentifier = none\n' > "$T/ext-v1leaf" +sign "$T/v1leaf.csr" "$OUT/selfsigned-v1-root.pem" "$T/rsa-v1.key" 301 "$T/ext-v1leaf" "$OUT/v1-root-leaf.pem" -days "$DAYS" +printf 'keyUsage = critical,keyCertSign,cRLSign\nsubjectKeyIdentifier = hash\n' > "$T/ext-nobc-ca" +rsa_key "$T/rsa-nobc.key" +selfsign "$T/rsa-nobc.key" "/O=Tessera Test/CN=Tessera Test NoBC CA" "$T/ext-nobc-ca" "$OUT/selfsigned-nobc-ca.pem" +printf 'keyUsage = critical,digitalSignature,keyEncipherment\nsubjectAltName = DNS:nobc.example.test\n' > "$T/ext-nobc-leaf" +rsa_key "$T/rsa-nobc2.key" +selfsign "$T/rsa-nobc2.key" "/O=Tessera Test/CN=nobc.example.test" "$T/ext-nobc-leaf" "$OUT/selfsigned-nobc-leaf.pem" + cat "$OUT/rsa-leaf.pem" "$OUT/rsa-inter.pem" "$OUT/rsa-root.pem" > "$OUT/rsa-fullchain.pem" openssl crl2pkcs7 -nocrl -certfile "$OUT/rsa-leaf.pem" -certfile "$OUT/rsa-inter.pem" -certfile "$OUT/rsa-root.pem" -out "$OUT/rsa-chain.p7b" openssl crl2pkcs7 -nocrl -certfile "$OUT/rsa-leaf.pem" -certfile "$OUT/rsa-inter.pem" -certfile "$OUT/rsa-root.pem" -outform DER -out "$OUT/rsa-chain.p7c" @@ -178,6 +193,19 @@ openssl pkcs12 -export -inkey "$OUT/ec-leaf-key.pem" -in "$OUT/ec-leaf.pem" -cer openssl pkcs12 -export -inkey "$OUT/ec-leaf-key.pem" -in "$OUT/ec-leaf.pem" -certfile "$T/ec-ca.pem" -passout "pass:$PW" "${COMPAT[@]}" -out "$OUT/ec-compat.pfx" cp "$OUT/rsa-modern.pfx" "$OUT/rsa-modern.bin" +# Passwoerter mit Umlauten und Eurozeichen (Review CR-03): modern = PBES2/AES-256 (Passwort als UTF-8), +# kompatibel = 3DES (PKCS#12-Ableitung, UTF-16). Datei rsa--.pfx, +# Passwoerter `pässwörd` bzw. `pw€`. +for spec in "umlaut:pässwörd" "euro:pw€"; do + name="${spec%%:*}"; pass="${spec#*:}" + openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$pass" -out "$OUT/rsa-$name-modern.pfx" + openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$pass" "${COMPAT[@]}" -out "$OUT/rsa-$name-compat.pfx" +done + +# Zu aufwendiger Passwortschutz (Review WR-04): 2 000 000 Ableitungsrunden, ueber der Grenze von 1 000 000. +openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$PW" -iter 2000000 -out "$OUT/rsa-expensive.pfx" +openssl pkcs8 -topk8 -v2 aes-256-cbc -iter 2000000 -in "$OUT/rsa-leaf-key.pem" -passout "pass:$PW" -out "$OUT/rsa-leaf-key-enc-expensive.pem" + # ----------------------------------------------------- ZIP mit Passwortschutz ( cd "$OUT" && zip -q -j -P "$PW" encrypted-entry.zip rsa-leaf.pem ) diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-euro-compat.pfx b/apps/api/src/cert-manager/__fixtures__/rsa-euro-compat.pfx new file mode 100644 index 0000000000000000000000000000000000000000..76c9c2d292900e9ca52abb2252bf8f123f430b23 GIT binary patch literal 4429 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 OKN~rmql5p~YX1Y*$$gvv literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-expensive.pfx b/apps/api/src/cert-manager/__fixtures__/rsa-expensive.pfx new file mode 100644 index 0000000000000000000000000000000000000000..5c71c084e10eb8693accc44b6b0e3b3efaee8d0f GIT binary patch literal 4598 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 MLgRA+|MMmP1K2!Ip8x;= literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-umlaut-modern.pfx b/apps/api/src/cert-manager/__fixtures__/rsa-umlaut-modern.pfx new file mode 100644 index 0000000000000000000000000000000000000000..488d361e5d374a472eca6f6755eb443b1a430e90 GIT binary patch literal 4595 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 RfF#E=d#h}Ln*YD6_CHKPhmQaN literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-ca.pem b/apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-ca.pem new file mode 100644 index 0000000..e2a5f38 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLTCCAhWgAwIBAgIUAgmiHMhq65ePF/4JWhFsBQphg78wDQYJKoZIhvcNAQEL +BQAwNjEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRl +c3QgTm9CQyBDQTAgFw0yNjEwMDkxNDUxNTRaGA8yMTI2MDkxNTE0NTE1NFowNjEV +MBMGA1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgTm9C +QyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKimDRiNLflWRPQ2 +Ws4g3eEIPIma0AL8lyIKbreZKHjeEIpVbUI9lTBoLM76ttNKY3qc8wxyur2w1pEH +3YpkDpXWYxXM0FpypMLqYqRcooT0/td5pdBEeoUbcMt9x37q/wUOtG5gaT+Y2U64 +9WwbNTWuXfWoZ1nglT8oS6zJW57U9/9UjteBrT1KngIXvzz/gNV5PnfNtxXogIgH +jfkJbXa0M8g808APP2mt5ENLjwkiHnKQaA2h44uVxeHm4PVyOp3H2+qS7m8Z4pPm +u649IXJGxgJrtUQep/IWWQrO5CfgEZ9UGDUR9r09P+WU+hvr4XCh6yCm+HKWPIij +mc/sta8CAwEAAaMxMC8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSAvlAVbZR0 +69z9WNh02qv4oI1voTANBgkqhkiG9w0BAQsFAAOCAQEAQGO0BWN3rftX0v7qTxj3 +dLuAGHZW7EQTgNMcd+3L1EZZZO2fs/fb5R0BPrp3FzsSYKiDASXLeQOO5mc2Ntck +tg41odnB8U3ATvxn+eQkQvA53HrVKJ7M2p6SQ+quBNNKUsRkI9NxnwyDzdCeLOxp +nxhuWlvGU1c/E2l5uTqh09Hf0ywVcxBeHhqtNn48sEeLDxtyxvwB6uGkHfzypIw6 +xLDzKOb6GZPX5ANFL3vzFyo0EceUNi12isDoCN5vwJ6Hc175qd3moio5pr4cZ2Gl +7I9vYXQT4U0LM/HFlNCBkRmLWyJ6FnQoAOMZ60HWSO+9514cjcscPeWvfPZ2aeRO +UQ== +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-leaf.pem b/apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-leaf.pem new file mode 100644 index 0000000..3469698 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/selfsigned-nobc-leaf.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDRTCCAi2gAwIBAgIUbUtcyfYpcNo6vopvCbXddMHczxIwDQYJKoZIhvcNAQEL +BQAwMzEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MRowGAYDVQQDDBFub2JjLmV4YW1w +bGUudGVzdDAgFw0yNjEwMDkxNDUxNTRaGA8yMTI2MDkxNTE0NTE1NFowMzEVMBMG +A1UECgwMVGVzc2VyYSBUZXN0MRowGAYDVQQDDBFub2JjLmV4YW1wbGUudGVzdDCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOM6MWRooapjjTL0o3ecC4cQ +7sV6NFLKzzhfnBSZTlaFE/DvYxgJ4ToLqAPcVt+Ol9CkTcf7YVzDlUE/eZoIOXkQ +w+FO+b0Qj4/8M2Mh1We0eZgak9O9FLoPFMZZO03rCFu3cr1QAQTGjjVn4vA59+3M +ZlkP133C7ZvPD5YN8JRTyCHG/nbCS/pYTqbfWJt7yyVMKFEglR3r1VjqXVrjG7/5 +wwBPIqo7hFY074cXlIlRp/SvCLzre45QOOnL4eGucRnoOddEAKEqWosjqv84fFjl +CdxWQTw600VFIz/WJQZtgMPReymhpJDBhfyaJF5Q8ZWSyaI1sp2twlrRwQybvWsC +AwEAAaNPME0wDgYDVR0PAQH/BAQDAgWgMBwGA1UdEQQVMBOCEW5vYmMuZXhhbXBs +ZS50ZXN0MB0GA1UdDgQWBBTVJOfoX1uJY7QAlvT65Q9kFCh3FDANBgkqhkiG9w0B +AQsFAAOCAQEAnnsIcJpdRR3KejtAPzAEfBR9VB6swD1xCWuMUExDAROsv8M9V520 +GOy9z2pYi5cgC4TVP2qHjpYjv8NZX9Anzt0Nq1XC93FL2CWJ6FR02l9vh0qY6p1Z +GORsSAa9K+B2CFfZ0IGwEZGjhu9tKucahGJot3viPgtu5HMVQO/tvLC7DKrOgVB5 +VwC53zbDEvuyWA7gHrlZvruqtyvW7y/hRGvxwXULZAJv4FuMV0al9kiEOZ8gcsVY +aIUJSZx5m10/G1OhXjbmF+B5YFkyLd9s3ag+Mq1rRt4W1+Zcs7vbmtDADGhGeClS +HUPsquidzPJgJlKhR+pc3yce0R+5gDdVNg== +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/selfsigned-v1-root.pem b/apps/api/src/cert-manager/__fixtures__/selfsigned-v1-root.pem new file mode 100644 index 0000000..45d0618 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/selfsigned-v1-root.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9TCCAd0CFCijxXjfHCuBLAb1mLSMIU/04OLTMA0GCSqGSIb3DQEBCwUAMDYx +FTATBgNVBAoMDFRlc3NlcmEgVGVzdDEdMBsGA1UEAwwUVGVzc2VyYSBUZXN0IFYx +IFJvb3QwIBcNMjYxMDA5MTQ1MjI4WhgPMjEyNjA5MTUxNDUyMjhaMDYxFTATBgNV +BAoMDFRlc3NlcmEgVGVzdDEdMBsGA1UEAwwUVGVzc2VyYSBUZXN0IFYxIFJvb3Qw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcuEOxOngIR6lcKR9uglsf +G1upAsWqdDJzNgz1MB5abyleoxSgLncYrf84DVCFIa05Z2GFZZbi1/bysMon1JfL +kPRw/E5KrHECeRi4nF/6DMICVHopnbnwlDKb3pgzFT/Fun05qbffeBx7eZfupCuI +bfOXCho+lEQFO+JjFIP+qwPAkNlDTdjrDaqMDy2rskj+DoMQC3RlaKbuaV7RCe6x +Z1JXew4CCXdGirehYMcF4PFgRdoTYgDk5LGyRh4CGC/uldf4I6CrUGeDEl2aSdks +MvRSv7nEKvK7x0XF8jtSKlt9qvOsUtKI2LnfnGDjggcwUGHnRHgAHMj1Kx4xsUJ1 +AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEd13u2UvqE1qZeFy4P3dzsRzTtaN+FD +jczMBI5DFfR27xrTZkkXBpvQgcJwhTa85GCwgw7H9Y0wtVHgtVt1Eq8Fw6Z7wqAF +zetmnA7RJG2vkv2d1+bsnaSqIIATCvECG+450V7fXgKKDf6A85ZliazZ0NU3HJA3 +gfktRTi5dk6Re6efdKsskie7/BnhmB8yPXkVR+DMGRytS6hnK3C699yrioEOhG6Q +BcdTCHtPYpnZJSaPHAMTnyHAiF6km6CzaBjN0cv+R1EFycCstzP9EqAsTCZLZZV5 +jeVEDww2BY7jnOETkDpMn0clvlC10IAeQVMb8rqH2e5meTl+LWUY7hg= +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/v1-root-leaf.pem b/apps/api/src/cert-manager/__fixtures__/v1-root-leaf.pem new file mode 100644 index 0000000..d0b11ee --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/v1-root-leaf.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDLDCCAhSgAwIBAgICAS0wDQYJKoZIhvcNAQELBQAwNjEVMBMGA1UECgwMVGVz +c2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgVjEgUm9vdDAgFw0yNjEw +MDkxNDUyMjhaGA8yMTI2MDkxNTE0NTIyOFowNjEVMBMGA1UECgwMVGVzc2VyYSBU +ZXN0MR0wGwYDVQQDDBR2MS1sZWFmLmV4YW1wbGUudGVzdDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAMhTYUAgnrgYseCSMYSeecJR2VvETwGcOykavi5A +9KlkHjoFJ2etF3r7IzvWeS5F6u7wAvuaNFTdzt/hNH+2bqLyTzOMGplFDGptfGKl +dh2oaw1qEgrifBMlVnQ098Del5RujuWCz3vfUuFtsDzubhVEgkJC9qcbsrA+WHJA +KmAGzFD7ezrIwBE6t9CC6VFPBMRNVNpbvRik3prUEH+pWjKP0N8dKKtOXjsKvVPu +fAzBuTxslFw5TmKSY9YwOlw8IQAEGgI7Xael4D7IzhTuGGmTUCccql7GU2DOIyOY +7mj2fQ0lQA5hY8jJkCFbB3WAK9bGwOwc5IbcAzYAf6xuqNMCAwEAAaNCMEAwHwYD +VR0RBBgwFoIUdjEtbGVhZi5leGFtcGxlLnRlc3QwHQYDVR0OBBYEFFfmr7+Nimg/ +G+Ai/TJy31WpKNkWMA0GCSqGSIb3DQEBCwUAA4IBAQA78ufqEU2AAyXZlAWEq1fZ +JfeYAMiMV5UOjheRJ9NCRUSsiV2/wzHsewqOfBcIgYh/Xluy886ytTH00vk7c9rt +CyqPfhtdotNU6Ls/1xK3aQJZ+D7U4LYYuvAnGtmn6gTc0D0pGo6NtUuAypGQb9zR +1m5x0WOPj9WUNLnVw6a/YkB6JFY8rJx2q6jOnq9IWfiGBOKsoFlFgcrsDtRby6Ez +R7/QnrQUQCbuuyDQSMzcWdulOC0QzJhSQau3Xsm8pWe2mv7oeba4NhdO1F+EhRCX +ckOFFwnvLBoADcALdmHHJ7NQFMeVHPVzx5GO+qA8JzFH0D8ZWJDUXGsrhb7ptKy8 +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/cert-aia-url.spec.ts b/apps/api/src/cert-manager/cert-aia-url.spec.ts new file mode 100644 index 0000000..41afdc8 --- /dev/null +++ b/apps/api/src/cert-manager/cert-aia-url.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + AIA_MAX_URLS, + acceptableIssuerUrl, + acceptableIssuerUrls, + rawIssuerEntries, +} from './cert-aia-url'; + +describe('acceptableIssuerUrl (Review IN-02)', () => { + it.each([ + 'http://pki.example.test/ca.crt', + 'https://pki.example.test/ca.crt', + 'http://pki.example.test', + 'https://pki.example.test:443/ca.crt', + ])('erlaubt %s', (entry) => { + expect(acceptableIssuerUrl(entry)).toBeInstanceOf(URL); + }); + + it.each([ + ['anderer Port', 'http://pki.example.test:8080/ca.crt'], + ['Zugangsdaten', 'http://user:pw@pki.example.test/ca.crt'], + ['ldap', 'ldap://ldap.example.test/cn=x'], + ['ftp', 'ftp://pki.example.test/ca.crt'], + ['zu lang', `http://pki.example.test/${'a'.repeat(2100)}`], + ['keine Adresse', 'kein url'], + ])('verwirft %s', (_name, entry) => { + expect(acceptableIssuerUrl(entry)).toBeNull(); + }); + + it('verwirft Nicht-Zeichenketten', () => { + expect(acceptableIssuerUrl(42)).toBeNull(); + expect(acceptableIssuerUrl(undefined)).toBeNull(); + }); +}); + +describe('acceptableIssuerUrls', () => { + it('liest eine Zeichenkette und eine Liste, filtert und begrenzt', () => { + const info = { + 'CA Issuers - URI': [ + 'http://a.example.test/1.crt', + 'http://b.example.test:8080/2.crt', + 'ldap://c.example.test/x', + 'http://d.example.test/3.crt', + 'http://e.example.test/4.crt', + 'http://f.example.test/5.crt', + ], + }; + const urls = acceptableIssuerUrls(info).map((u) => u.hostname); + expect(urls).toEqual(['a.example.test', 'd.example.test', 'e.example.test']); + expect(urls).toHaveLength(AIA_MAX_URLS); + expect( + acceptableIssuerUrls({ 'CA Issuers - URI': 'http://a.example.test/1.crt' }), + ).toHaveLength(1); + expect(acceptableIssuerUrls(undefined)).toEqual([]); + }); + + it('rawIssuerEntries zaehlt auch verworfene Eintraege', () => { + expect(rawIssuerEntries({ 'CA Issuers - URI': ['http://x:81/', 'ldap://y/'] })).toHaveLength(2); + expect(rawIssuerEntries({})).toEqual([]); + }); +}); diff --git a/apps/api/src/cert-manager/cert-aia-url.ts b/apps/api/src/cert-manager/cert-aia-url.ts new file mode 100644 index 0000000..f5831ed --- /dev/null +++ b/apps/api/src/cert-manager/cert-aia-url.ts @@ -0,0 +1,48 @@ +/** + * Welche „CA Issuers“-Adressen eines Zertifikats ueberhaupt abgerufen werden duerfen + * (Review IN-02, quick-261009-ikt). Eine einzige Stelle fuer die Anzeige (cert-model.ts: erscheint der + * Knopf?) und den Abruf (cert-aia.ts): der Knopf erscheint nur, wenn ein Abruf auch moeglich ist. + * + * Erlaubt: http/https, ohne Benutzername und Kennwort, hoechstens 2048 Zeichen, nur der + * Standardport (80/443, sonst waere der Abruf ein Portscanner). Ob der Server oeffentlich ist, + * prueft cert-aia.ts beim Abruf selbst. + */ + +export const AIA_MAX_URLS = 3; +export const MAX_URL_LENGTH = 2048; + +/** Nur der Standardport (leer = 80/443). */ +export function hasDefaultPort(url: URL): boolean { + return url.port === ''; +} + +/** Die Adresse als URL, wenn sie abgerufen werden duerfte; sonst null. */ +export function acceptableIssuerUrl(entry: unknown): URL | null { + if (typeof entry !== 'string' || entry.length > MAX_URL_LENGTH) return null; + try { + const url = new URL(entry); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + if (url.username || url.password) return null; + if (!hasDefaultPort(url)) return null; + return url; + } catch { + return null; + } +} + +/** Alle „CA Issuers“-Eintraege aus den Zugriffsinformationen, unveraendert. */ +export function rawIssuerEntries(infoAccess: unknown): unknown[] { + const raw = (infoAccess as Record | undefined)?.['CA Issuers - URI']; + return Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []; +} + +/** Die abrufbaren Adressen, hoechstens AIA_MAX_URLS. */ +export function acceptableIssuerUrls(infoAccess: unknown): URL[] { + const urls: URL[] = []; + for (const entry of rawIssuerEntries(infoAccess)) { + const url = acceptableIssuerUrl(entry); + if (url) urls.push(url); + if (urls.length >= AIA_MAX_URLS) break; + } + return urls; +} diff --git a/apps/api/src/cert-manager/cert-aia.spec.ts b/apps/api/src/cert-manager/cert-aia.spec.ts index 0d2ff07..af5b511 100644 --- a/apps/api/src/cert-manager/cert-aia.spec.ts +++ b/apps/api/src/cert-manager/cert-aia.spec.ts @@ -185,7 +185,7 @@ describe('fetchIssuer: Weiterleitungen, Grenzen, Zeit', () => { const result = await failure( fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }), ); - expect(result).toEqual({ status: 422, code: 'aiaInternal' }); + expect(result).toEqual({ status: 422, code: 'aiaNotAllowed' }); expect(calls).toHaveLength(1); }); @@ -195,7 +195,7 @@ describe('fetchIssuer: Weiterleitungen, Grenzen, Zeit', () => { await failure( fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: withLogin.impl, isPublic: publicAlways }), ), - ).toEqual({ status: 422, code: 'aiaInternal' }); + ).toEqual({ status: 422, code: 'aiaNotAllowed' }); const ftp = fakeFetch(() => redirect('ftp://pki.example.test/x')); expect( await failure( diff --git a/apps/api/src/cert-manager/cert-aia.ts b/apps/api/src/cert-manager/cert-aia.ts index e949ae8..04db93b 100644 --- a/apps/api/src/cert-manager/cert-aia.ts +++ b/apps/api/src/cert-manager/cert-aia.ts @@ -3,9 +3,16 @@ import * as dns from 'node:dns'; import { Logger } from '@nestjs/common'; import { Agent, type Dispatcher, fetch as undiciFetch } from 'undici'; import { isPrivateIpAddress, isPublicHttpUrl } from '../common/public-url-guard'; +import { + acceptableIssuerUrls, + hasDefaultPort, + MAX_URL_LENGTH, + rawIssuerEntries, +} from './cert-aia-url'; import { leadingDerSequence, pkcs7Certificates } from './cert-model'; import { safeBaseName } from './cert-names'; import { type CertErrorCode, certError } from './cert-types'; +import { scanPemBlocks } from './pem-scan'; /** * „Fehlendes Zertifikat holen“ (quick-261009-ikt, D-03, D-22). @@ -41,8 +48,6 @@ import { type CertErrorCode, certError } from './cert-types'; export const AIA_TIMEOUT_MS = 8000; export const AIA_MAX_REDIRECTS = 3; export const AIA_MAX_BYTES = 256 * 1024; -export const AIA_MAX_URLS = 3; -const MAX_URL_LENGTH = 2048; const MAX_ANSWER_CERTIFICATES = 20; export interface FetchIssuerResult { @@ -133,34 +138,12 @@ function discard(response: { body?: { cancel(): Promise } | null }): void } } -/** Die „CA Issuers“-Adressen eines Zertifikats: nur http/https, ohne Zugangsdaten, hoechstens drei. */ -function issuerUrls(target: X509Certificate): URL[] { +/** Die abrufbaren „CA Issuers“-Adressen eines Zertifikats (gemeinsamer Filter, cert-aia-url.ts). */ +function issuerUrls(target: X509Certificate): { urls: URL[]; declared: number } { const info = (target.toLegacyObject() as { infoAccess?: Record }).infoAccess; - const raw = info?.['CA Issuers - URI']; - const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []; - const urls: URL[] = []; - for (const entry of list) { - if (typeof entry !== 'string' || entry.length > MAX_URL_LENGTH) continue; - try { - const url = new URL(entry); - if (url.protocol !== 'http:' && url.protocol !== 'https:') continue; - if (url.username || url.password) continue; - urls.push(url); - } catch { - // keine gueltige Adresse: ueberspringen - } - if (urls.length >= AIA_MAX_URLS) break; - } - return urls; + return { urls: acceptableIssuerUrls(info), declared: rawIssuerEntries(info).length }; } -/** Nur der Standardport (leer = 80/443), sonst waere der Abruf ein Portscanner. */ -function hasDefaultPort(url: URL): boolean { - return url.port === ''; -} - -const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g; - /** Zertifikate aus einer Antwort: DER-Zertifikat, PKCS#7 (DER oder PEM) oder PEM-Text. */ function certificatesFromAnswer(data: Buffer): X509Certificate[] { const found: X509Certificate[] = []; @@ -182,12 +165,12 @@ function certificatesFromAnswer(data: Buffer): X509Certificate[] { if (data.includes('-----BEGIN ')) { const text = data.toString('latin1'); - for (const match of text.matchAll(PEM_BLOCK)) { - const label = match[1]; - if (label === 'CERTIFICATE' || label === 'X509 CERTIFICATE') { - add(match[0]); - } else if (label === 'PKCS7' || label === 'CMS') { - addPkcs7(Buffer.from(match[2].replace(/\s+/g, ''), 'base64')); + for (const block of scanPemBlocks(text)) { + if (found.length >= MAX_ANSWER_CERTIFICATES) break; + if (block.label === 'CERTIFICATE' || block.label === 'X509 CERTIFICATE') { + add(block.text); + } else if (block.label === 'PKCS7' || block.label === 'CMS') { + addPkcs7(Buffer.from(block.body.replace(/\s+/g, ''), 'base64')); } } return found; @@ -222,12 +205,14 @@ const FAILURE_RANK: Record = { aiaNotIssuer: 4, aiaTooLarge: 3, aiaUnreachable: 2, + aiaNotAllowed: 1, aiaInternal: 1, }; const FAILURE_STATUS: Record = { aiaNotIssuer: 422, aiaInternal: 422, + aiaNotAllowed: 422, aiaTooLarge: 502, aiaUnreachable: 502, }; @@ -235,13 +220,15 @@ const FAILURE_STATUS: Record = { const FAILURE_TEXT: Record = { aiaNotIssuer: 'The downloaded certificate did not issue this certificate', aiaInternal: 'The issuer address is not a public address', + aiaNotAllowed: + 'The issuer address is not allowed (only http or https on the default port, without credentials)', aiaTooLarge: 'The issuer answer is too large', aiaUnreachable: 'The issuer address could not be reached', }; /** * Holt das Ausstellerzertifikat zum uebergebenen Zertifikat (PEM). Fehler: notACertificate 400, - * aiaMissing 422, aiaInternal 422, aiaNotIssuer 422, aiaUnreachable 502, aiaTooLarge 502. + * aiaMissing 422, aiaNotAllowed 422, aiaInternal 422, aiaNotIssuer 422, aiaUnreachable 502, aiaTooLarge 502. */ export async function fetchIssuer( pem: string, @@ -254,10 +241,14 @@ export async function fetchIssuer( return certError('notACertificate', 400, 'The provided text is not a certificate'); } - const urls = issuerUrls(target); - if (urls.length === 0) { + const { urls, declared } = issuerUrls(target); + if (declared === 0) { return certError('aiaMissing', 422, 'The certificate names no issuer address'); } + if (urls.length === 0) { + // Adressen vorhanden, aber keine abrufbare (Zugangsdaten, anderer Port, kein http/https, zu lang) + return certError('aiaNotAllowed', 422, FAILURE_TEXT.aiaNotAllowed); + } const fetchImpl = opts.fetchImpl ?? undiciFetch; const isPublic = opts.isPublic ?? isPublicHttpUrl; @@ -275,9 +266,8 @@ export async function fetchIssuer( const run = async (): Promise => { let current = first; for (let hop = 0; ; hop++) { - if (!hasDefaultPort(current) || !(await isPublic(current))) { - return { ok: false, code: 'aiaInternal' }; - } + if (!hasDefaultPort(current)) return { ok: false, code: 'aiaNotAllowed' }; + if (!(await isPublic(current))) return { ok: false, code: 'aiaInternal' }; let response: Awaited>; try { @@ -309,7 +299,7 @@ export async function fetchIssuer( return { ok: false, code: 'aiaUnreachable' }; } if (next.username || next.password || next.href.length > MAX_URL_LENGTH) { - return { ok: false, code: 'aiaInternal' }; + return { ok: false, code: 'aiaNotAllowed' }; } current = next; continue; diff --git a/apps/api/src/cert-manager/cert-analyze.spec.ts b/apps/api/src/cert-manager/cert-analyze.spec.ts index 1d01806..e746c21 100644 --- a/apps/api/src/cert-manager/cert-analyze.spec.ts +++ b/apps/api/src/cert-manager/cert-analyze.spec.ts @@ -1,6 +1,8 @@ +import { generateKeyPairSync } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import AdmZip from 'adm-zip'; +import * as forge from 'node-forge'; import { describe, expect, it } from 'vitest'; import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze'; import type { CertItem, CsrItem, KeyItem } from './cert-types'; @@ -263,3 +265,116 @@ describe('analyzeWorkingSet: Schluessel, PFX und Anfragen mit Passwort je Datei' expect(r.locked[0]?.reason).toBe('passwordNeeded'); }); }); + +// --------------------------------------------------------------------------- +// Review WR-05: Obergrenzen je Anfrage +// --------------------------------------------------------------------------- + +/** n verschiedene selbstsignierte Zertifikate in einer PEM-Datei (nur fuer den Test, mit forge erzeugt). */ +function manyCertificates(n: number): Buffer { + const keys = forge.pki.rsa.generateKeyPair({ bits: 1024, e: 0x10001 }); + const pems: string[] = []; + for (let i = 0; i < n; i++) { + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = (i + 1).toString(16).padStart(2, '0'); + cert.validity.notBefore = new Date(Date.now() - 86_400_000); + cert.validity.notAfter = new Date(Date.now() + 86_400_000 * 365); + const attrs = [{ name: 'commonName', value: `massen-${i}.example.test` }]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.sign(keys.privateKey, forge.md.sha256.create()); + pems.push(forge.pki.certificateToPem(cert)); + } + return Buffer.from(pems.join('\n')); +} + +function statusAndCode(run: () => unknown): { status: number; code: string } { + try { + run(); + } catch (error) { + const e = error as { getStatus(): number; getResponse(): { code: string } }; + return { status: e.getStatus(), code: e.getResponse().code }; + } + throw new Error('expected a throw'); +} + +describe('analyzeWorkingSet: Obergrenzen je Anfrage', () => { + it('genau 200 Zertifikate sind erlaubt', () => { + const result = analyzeWorkingSet([ + { originalname: 'viele.pem', buffer: manyCertificates(200) }, + ]); + expect(result.items.filter((i) => i.kind === 'certificate')).toHaveLength(200); + }, 60_000); + + it('201 Zertifikate in einer Datei: 413 tooManyItems, und zwar schnell', () => { + const buffer = manyCertificates(201); + const started = performance.now(); + expect(statusAndCode(() => analyzeWorkingSet([{ originalname: 'viele.pem', buffer }]))).toEqual( + { + status: 413, + code: 'tooManyItems', + }, + ); + expect(performance.now() - started).toBeLessThan(5000); + }, 60_000); + + it('die Grenze gilt ueber alle Dateien und auch im ZIP', () => { + const half = manyCertificates(101); + const other = manyCertificates(101); + const zip = new AdmZip(); + zip.addFile('a.pem', other); + const files = [ + { originalname: 'eins.pem', buffer: half }, + { originalname: 'zwei.zip', buffer: zip.toBuffer() }, + ]; + expect(statusAndCode(() => analyzeWorkingSet(files)).code).toBe('tooManyItems'); + }, 60_000); + + it('51 Schluessel: 413 tooManyItems', () => { + const keys = Array.from({ length: 51 }, () => + generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }), + ); + const buffer = Buffer.from(keys.join('\n')); + expect( + statusAndCode(() => analyzeWorkingSet([{ originalname: 'keys.pem', buffer }])).code, + ).toBe('tooManyItems'); + }); + + it('50 Schluessel sind erlaubt', () => { + const keys = Array.from({ length: 50 }, () => + generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }), + ); + const result = analyzeWorkingSet([ + { originalname: 'keys.pem', buffer: Buffer.from(keys.join('\n')) }, + ]); + expect(result.items.filter((i) => i.kind === 'privateKey')).toHaveLength(50); + }); + + it('unlesbare Dateien zaehlen nicht gegen die Grenzen', () => { + const junk = Array.from({ length: 30 }, (_, i) => ({ + originalname: `muell-${i}.bin`, + buffer: Buffer.from(`kein Zertifikat ${i}`), + })); + const result = analyzeWorkingSet(junk); + expect(result.ignored).toHaveLength(30); + }); + + it('der Rechenaufwand des Passwortschutzes gilt je Anfrage (viele teure Dateien)', () => { + const files = Array.from({ length: 12 }, (_, i) => ({ + originalname: `teuer-${i}.pfx`, + buffer: fx('rsa-expensive.pfx'), + })); + const started = performance.now(); + const result = analyzeWorkingSet(files, ['Test-Pass-123']); + expect(result.items).toEqual([]); + expect(result.ignored.every((e) => e.reason === 'protectionTooExpensive')).toBe(true); + expect(performance.now() - started).toBeLessThan(2000); + }); +}); diff --git a/apps/api/src/cert-manager/cert-analyze.ts b/apps/api/src/cert-manager/cert-analyze.ts index d775c2f..ee405a6 100644 --- a/apps/api/src/cert-manager/cert-analyze.ts +++ b/apps/api/src/cert-manager/cert-analyze.ts @@ -1,3 +1,4 @@ +import { RequestBudget } from './cert-budget'; import { buildChains, matchKeys } from './cert-chain'; import { candidatePasswords } from './cert-keys'; import { detectBlob } from './cert-model'; @@ -51,6 +52,8 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = [] const ignored: IgnoredEntry[] = []; const locked: LockedEntry[] = []; const lockedSeen = new Set(); + // Gemeinsame Grenzen aller Dateien dieser Anfrage (Review WR-04, WR-05). + const budget = new RequestBudget(); files.forEach((f, index) => { const path = cleanSourcePath(f.originalname); @@ -60,6 +63,7 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = [] path, passwords: candidatePasswords(own, passwords), ownPassword: own, + budget, }); for (const item of result.items) { const known = byId.get(item.id); diff --git a/apps/api/src/cert-manager/cert-budget.ts b/apps/api/src/cert-manager/cert-budget.ts new file mode 100644 index 0000000..b56de16 --- /dev/null +++ b/apps/api/src/cert-manager/cert-budget.ts @@ -0,0 +1,71 @@ +import { HttpException } from '@nestjs/common'; +import { certError } from './cert-types'; + +/** + * Arbeitsgrenzen je Anfrage (Review WR-04, WR-05, quick-261009-ikt). + * + * Node ist einfaedig: alles, was ein Hochladender in einer Anfrage ausloesen kann, blockiert die + * ganze API. Darum gibt es zwei Grenzen, die ueber alle Dateien einer Anfrage zusammen gelten: + * + * - Anzahl: hoechstens 200 Zertifikate, 50 Schluessel, 50 Zertifikatsanfragen. Dahinter steht der + * quadratische Kettenbau und die Zuordnung. Mehr: 413 `tooManyItems` fuer die ganze Anfrage. + * - Rechenaufwand des Passwortschutzes: ein einzelnes Schluesselableitungsverfahren (PBKDF2 oder + * PKCS#12-KDF) darf hoechstens 1 000 000 Runden haben, alle zusammen hoechstens 6 000 000 je + * Anfrage (rund 3 Sekunden mit der nativen PBKDF2). Mehr: die Datei wird mit Grund + * `protectionTooExpensive` uebersprungen, bevor entschluesselt wird. + */ + +export const MAX_CERTS_PER_REQUEST = 200; +export const MAX_KEYS_PER_REQUEST = 50; +export const MAX_CSRS_PER_REQUEST = 50; + +export const MAX_KDF_ITERATIONS = 1_000_000; +export const MAX_KDF_WORK = 6_000_000; + +/** Der Passwortschutz einer Datei ist zu aufwendig zu pruefen (zu viele Runden, oder die Anfragegrenze ist aufgebraucht). */ +export class KdfTooExpensiveError extends Error { + constructor() { + super('The password protection needs too much work to check'); + this.name = 'KdfTooExpensiveError'; + } +} + +export class RequestBudget { + private certs = 0; + private keys = 0; + private csrs = 0; + private kdfWork = 0; + + spendCert(): void { + if (++this.certs > MAX_CERTS_PER_REQUEST) tooMany(); + } + + spendKey(): void { + if (++this.keys > MAX_KEYS_PER_REQUEST) tooMany(); + } + + spendCsr(): void { + if (++this.csrs > MAX_CSRS_PER_REQUEST) tooMany(); + } + + /** Rechenaufwand einer Schluesselableitung in Runden; wirft KdfTooExpensiveError, bevor sie laeuft. */ + spendKdf(iterations: number): void { + if (!Number.isFinite(iterations) || iterations < 0 || iterations > MAX_KDF_ITERATIONS) { + throw new KdfTooExpensiveError(); + } + this.kdfWork += iterations; + if (this.kdfWork > MAX_KDF_WORK) throw new KdfTooExpensiveError(); + } +} + +function tooMany(): never { + return certError('tooManyItems', 413, 'Too many certificates, keys or requests in one request'); +} + +/** + * Fuer Stellen, die Fehler beim Lesen einer Datei bewusst verschlucken: eine ueberschrittene + * Anfragegrenze (`tooManyItems`, eine HttpException) gehoert dem Aufrufer und wird weitergereicht. + */ +export function rethrowRequestError(error: unknown): void { + if (error instanceof HttpException) throw error; +} diff --git a/apps/api/src/cert-manager/cert-chain.spec.ts b/apps/api/src/cert-manager/cert-chain.spec.ts index 556da40..eb8a643 100644 --- a/apps/api/src/cert-manager/cert-chain.spec.ts +++ b/apps/api/src/cert-manager/cert-chain.spec.ts @@ -222,3 +222,33 @@ describe('matchKeys', () => { expect(keysOf(items)[0].certIds).toHaveLength(1); }); }); + +describe('buildChains: Wurzel der Version 1 (Review WR-07)', () => { + it('Kette vom Blatt zur v1-Wurzel ist vollstaendig und die Wurzel wird erkannt', () => { + const items = ['v1-root-leaf.pem', 'selfsigned-v1-root.pem'].flatMap((name) => + detectBlob(readFileSync(join(__dirname, '__fixtures__', name)), { + file: 0, + path: name, + passwords: [], + }).items.filter((i): i is CertItem => i.kind === 'certificate'), + ); + const { chains } = buildChains(items); + expect(chains).toHaveLength(1); + expect(chains[0].complete).toBe(true); + const root = items.find((c) => c.cn === 'Tessera Test V1 Root'); + expect(chains[0].rootId).toBe(root?.id); + }); + + it('die v1-Wurzel ist kein zusaetzlicher Kettenkopf neben dem Blatt', () => { + const items = ['v1-root-leaf.pem', 'selfsigned-v1-root.pem'].flatMap((name) => + detectBlob(readFileSync(join(__dirname, '__fixtures__', name)), { + file: 0, + path: name, + passwords: [], + }).items.filter((i): i is CertItem => i.kind === 'certificate'), + ); + expect(buildChains(items).chains.map((c) => c.headId)).toEqual([ + items.find((c) => c.cn === 'v1-leaf.example.test')?.id, + ]); + }); +}); diff --git a/apps/api/src/cert-manager/cert-keys.spec.ts b/apps/api/src/cert-manager/cert-keys.spec.ts index 181a223..4723292 100644 --- a/apps/api/src/cert-manager/cert-keys.spec.ts +++ b/apps/api/src/cert-manager/cert-keys.spec.ts @@ -1,8 +1,16 @@ import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import * as forge from 'node-forge'; import { describe, expect, it } from 'vitest'; -import { candidatePasswords, exportKey, keyIdOf, MAX_PASSWORDS } from './cert-keys'; +import { MAX_KDF_WORK, RequestBudget } from './cert-budget'; +import { + candidatePasswords, + exportKey, + keyIdOf, + MAX_PASSWORDS, + pkcs8Iterations, +} from './cert-keys'; import { detectBlob } from './cert-model'; import type { KeyItem } from './cert-types'; @@ -126,6 +134,67 @@ describe('verschluesselte Schluessel', () => { }); }); +describe('verschluesselter Schluessel mit zu vielen Ableitungsrunden (Review WR-04)', () => { + it('2 000 000 Runden: protectionTooExpensive, schnell, nicht gesperrt', () => { + const started = performance.now(); + const result = detect('rsa-leaf-key-enc-expensive.pem', PASSWORD); + expect(result.items).toEqual([]); + expect(result.locked).toEqual([]); + expect(result.ignored).toEqual([ + { file: 0, path: 'rsa-leaf-key-enc-expensive.pem', reason: 'protectionTooExpensive' }, + ]); + // Die Ableitung (rund 1 s mit OpenSSL) laeuft gar nicht erst. + expect(performance.now() - started).toBeLessThan(500); + }); + + it('pkcs8Iterations liest die Runden aus PBES2 und aelteren Verfahren', () => { + const pbes2 = readFileSync( + join(__dirname, '__fixtures__', 'rsa-leaf-key-enc-pkcs8.pem'), + 'utf8', + ); + const der = Buffer.from( + pbes2 + .split('\n') + .filter((l) => !l.startsWith('-----')) + .join(''), + 'base64', + ); + const iterations = pkcs8Iterations( + forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), { + strict: false, + } as unknown as boolean), + ); + expect(iterations).toBeGreaterThan(0); + expect(iterations).toBeLessThanOrEqual(1_000_000); + const legacy = fx('ec-leaf-key-enc-pkcs8.der'); // PBE-SHA1-3DES (PKCS#12-PBE) + const legacyIterations = pkcs8Iterations( + forge.asn1.fromDer(forge.util.createBuffer(legacy.toString('binary')), { + strict: false, + } as unknown as boolean), + ); + expect(legacyIterations).toBeGreaterThan(0); + }); + + it('der Rechenaufwand der Anfrage wird ueber Dateien und Passwoerter summiert', () => { + const budget = new RequestBudget(); + const ctx = { + file: 0, + path: 'rsa-leaf-key-enc-pkcs8.pem', + passwords: ['falsch'], + ownPassword: 'falsch', + budget, + }; + // Erlaubt bleibt es nur, solange die Summe unter der Anfragegrenze liegt. + for (let spent = 0; spent < MAX_KDF_WORK - 100; spent += 1_000_000) { + budget.spendKdf(Math.min(1_000_000, MAX_KDF_WORK - 100 - spent)); + } + const result = detectBlob(fx('rsa-leaf-key-enc-pkcs8.pem'), ctx); + expect(result.ignored).toEqual([ + { file: 0, path: 'rsa-leaf-key-enc-pkcs8.pem', reason: 'protectionTooExpensive' }, + ]); + }); +}); + describe('Schluessel im Text', () => { it('mehrere Bloecke in einer Datei: Zertifikat und Schluessel', () => { const both = Buffer.concat([fx('rsa-leaf.pem'), Buffer.from('\n'), fx('rsa-leaf-key.pem')]); diff --git a/apps/api/src/cert-manager/cert-keys.ts b/apps/api/src/cert-manager/cert-keys.ts index ab6f0f0..f75c94d 100644 --- a/apps/api/src/cert-manager/cert-keys.ts +++ b/apps/api/src/cert-manager/cert-keys.ts @@ -1,5 +1,6 @@ import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto'; import * as forge from 'node-forge'; +import { KdfTooExpensiveError, RequestBudget } from './cert-budget'; import { certError, type IgnoredEntry, @@ -34,6 +35,8 @@ export interface KeyContext { passwords: string[]; /** Das fuer genau diese Datei eingegebene Passwort (leer, wenn keines) */ ownPassword?: string; + /** Grenzen der Anfrage (Anzahl, Rechenaufwand); ohne Angabe gelten sie nicht */ + budget?: RequestBudget; } export function sha256Hex(data: Buffer | string): string { @@ -122,6 +125,7 @@ function lockReason(ctx: KeyContext): 'passwordNeeded' | 'passwordWrong' { } function locked(ctx: KeyContext): KeyDetectResult { + ctx.budget?.spendKey(); return { items: [], ignored: [], @@ -130,6 +134,7 @@ function locked(ctx: KeyContext): KeyDetectResult { } function found(key: KeyObject, ctx: KeyContext, wasEncrypted: boolean): KeyDetectResult { + ctx.budget?.spendKey(); return { items: [keyItemFromObject(key, { file: ctx.file, path: ctx.path }, wasEncrypted)], ignored: [], @@ -137,12 +142,72 @@ function found(key: KeyObject, ctx: KeyContext, wasEncrypted: boolean): KeyDetec }; } -/** Probiert die Kandidaten der Reihe nach; der erste, der den Schluessel oeffnet, gewinnt. */ +function tooExpensive(ctx: KeyContext): KeyDetectResult { + return { + items: [], + ignored: [{ file: ctx.file, path: ctx.path, reason: 'protectionTooExpensive' }], + locked: [], + }; +} + +const OID_PBES2 = '1.2.840.113549.1.5.13'; +const OID_PBKDF2 = '1.2.840.113549.1.5.12'; + +/** Wert einer ASN.1-INTEGER als Zahl; Infinity, wenn sie nicht in 48 Bit passt (dann ist sie zu gross). */ +function integerValue(node: forge.asn1.Asn1 | undefined): number | null { + if (!node || node.type !== forge.asn1.Type.INTEGER || typeof node.value !== 'string') return null; + const hex = forge.util.bytesToHex(node.value); + if (hex.length > 12 && !/^0+$/.test(hex.slice(0, hex.length - 12))) + return Number.POSITIVE_INFINITY; + return Number.parseInt(hex, 16); +} + +function childrenOf(node: forge.asn1.Asn1 | undefined): forge.asn1.Asn1[] { + return node && Array.isArray(node.value) ? (node.value as forge.asn1.Asn1[]) : []; +} + +/** + * Ableitungsrunden des Passwortschutzes eines verschluesselten PKCS#8 (EncryptedPrivateKeyInfo), + * gelesen aus der Verfahrensangabe, bevor irgendetwas entschluesselt wird (Review WR-04). + * PBES2 mit PBKDF2: die Rundenzahl der PBKDF2-Parameter; aeltere Verfahren (PBES1, PKCS#12-PBE): + * die INTEGER der Parameter (Salz, Runden). Nicht erkennbar (z. B. scrypt): 0, dort begrenzt + * OpenSSL den Speicher selbst. + */ +export function pkcs8Iterations(root: forge.asn1.Asn1): number { + const algorithm = childrenOf(root)[0]; + const [oidNode, params] = childrenOf(algorithm); + if (!oidNode || typeof oidNode.value !== 'string') return 0; + const oid = forge.asn1.derToOid(oidNode.value); + if (oid === OID_PBES2) { + const kdf = childrenOf(params)[0]; + const [kdfOid, kdfParams] = childrenOf(kdf); + if (!kdfOid || typeof kdfOid.value !== 'string') return 0; + if (forge.asn1.derToOid(kdfOid.value) !== OID_PBKDF2) return 0; + return ( + childrenOf(kdfParams) + .map(integerValue) + .find((n) => n !== null) ?? 0 + ); + } + return ( + childrenOf(params) + .map(integerValue) + .find((n) => n !== null) ?? 0 + ); +} + +/** + * Probiert die Kandidaten der Reihe nach; der erste, der den Schluessel oeffnet, gewinnt. Jeder + * Versuch kostet `iterations` Runden von der Anfragegrenze; zu teuer: KdfTooExpensiveError. + */ function openWithPasswords( open: (passphrase: string) => KeyObject, ctx: KeyContext, + iterations = 0, ): KeyObject | null { + const budget = ctx.budget ?? new RequestBudget(); for (const passphrase of ctx.passwords) { + budget.spendKdf(iterations); try { return open(passphrase); } catch { @@ -152,6 +217,27 @@ function openWithPasswords( return null; } +/** Base64-Rumpf eines PEM-Blocks (ohne Kopfzeilen mit Doppelpunkt) als DER. */ +function derOfPemText(text: string): Buffer | null { + const lines = text.split(/\r?\n/).slice(1, -1); + const body = lines + .filter((line) => !line.includes(':')) + .join('') + .replace(/\s+/g, ''); + return body ? Buffer.from(body, 'base64') : null; +} + +/** Rundenzahl eines verschluesselten PKCS#8 im PEM; 0, wenn nicht erkennbar (dann scheitert Node ohnehin). */ +function pemPkcs8Iterations(text: string, label: string): number { + if (label !== 'ENCRYPTED PRIVATE KEY') return 0; // klassisch (Proc-Type): eine Runde MD5 + try { + const der = derOfPemText(text); + return der ? pkcs8Iterations(readAsn1(der)) : 0; + } catch { + return 0; + } +} + /** * Ein PEM-Block mit Etikett „... PRIVATE KEY“. `text` ist der ganze Block samt BEGIN/END-Zeilen, * `encrypted` ist wahr bei „ENCRYPTED PRIVATE KEY“ oder einer Kopfzeile `Proc-Type: 4,ENCRYPTED`. @@ -164,8 +250,9 @@ export function keyFromPemBlock( ): KeyDetectResult | null { if (!label.endsWith('PRIVATE KEY')) return null; if (!encrypted && label !== 'ENCRYPTED PRIVATE KEY') { + let plain: KeyObject; try { - return found(createPrivateKey(text), ctx, false); + plain = createPrivateKey(text); } catch { return { items: [], @@ -173,9 +260,19 @@ export function keyFromPemBlock( locked: [], }; } + return found(plain, ctx, false); + } + try { + const key = openWithPasswords( + (passphrase) => createPrivateKey({ key: text, passphrase }), + ctx, + pemPkcs8Iterations(text, label), + ); + return key ? found(key, ctx, true) : locked(ctx); + } catch (error) { + if (error instanceof KdfTooExpensiveError) return tooExpensive(ctx); + throw error; } - const key = openWithPasswords((passphrase) => createPrivateKey({ key: text, passphrase }), ctx); - return key ? found(key, ctx, true) : locked(ctx); } /** ASN.1 mit unveraenderten Bitfolgen; Typdefinition kennt nur `strict: boolean`, forge nimmt ein Objekt. */ @@ -215,18 +312,26 @@ export function keyFromDer(der: Buffer, ctx: KeyContext): KeyDetectResult | null return null; } if (isEncryptedPkcs8(root)) { - const key = openWithPasswords( - (passphrase) => createPrivateKey({ key: der, format: 'der', type: 'pkcs8', passphrase }), - ctx, - ); - return key ? found(key, ctx, true) : locked(ctx); + try { + const key = openWithPasswords( + (passphrase) => createPrivateKey({ key: der, format: 'der', type: 'pkcs8', passphrase }), + ctx, + pkcs8Iterations(root), + ); + return key ? found(key, ctx, true) : locked(ctx); + } catch (error) { + if (error instanceof KdfTooExpensiveError) return tooExpensive(ctx); + throw error; + } } for (const type of DER_KEY_TYPES) { + let plain: KeyObject; try { - return found(createPrivateKey({ key: der, format: 'der', type }), ctx, false); + plain = createPrivateKey({ key: der, format: 'der', type }); } catch { - // anderer Aufbau: naechsten Typ probieren + continue; // anderer Aufbau: naechsten Typ probieren } + return found(plain, ctx, false); } return null; } diff --git a/apps/api/src/cert-manager/cert-manager.changelog.ts b/apps/api/src/cert-manager/cert-manager.changelog.ts index 92a477b..9337731 100644 --- a/apps/api/src/cert-manager/cert-manager.changelog.ts +++ b/apps/api/src/cert-manager/cert-manager.changelog.ts @@ -22,8 +22,8 @@ export const CERT_MANAGER_CHANGELOG: ModuleChangelog = [ }, { kind: 'new', - de: 'Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt.', - en: 'All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption.', + de: 'Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt, Passwörter dürfen Umlaute enthalten. Beim Aufteilen bleiben geschützte Schlüssel geschützt.', + en: 'All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption, passwords may contain umlauts. When splitting, protected keys stay protected.', }, { kind: 'new', diff --git a/apps/api/src/cert-manager/cert-manager.controller.ts b/apps/api/src/cert-manager/cert-manager.controller.ts index 92f422c..7342644 100644 --- a/apps/api/src/cert-manager/cert-manager.controller.ts +++ b/apps/api/src/cert-manager/cert-manager.controller.ts @@ -6,6 +6,7 @@ import { type FetchIssuerResult, fetchIssuer } from './cert-aia'; import { analyzeWorkingSet } from './cert-analyze'; import { buildOutput } from './cert-output'; import { type AnalysisResult, type BuildResult, certError } from './cert-types'; +import { createTotalLimitedStorage } from './cert-upload'; import { BuildOutputDto } from './dto/cert-build.dto'; import { FetchIssuerDto } from './dto/cert-fetch-issuer.dto'; @@ -14,6 +15,20 @@ export const CERT_MAX_FILES = 30; export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024; export const CERT_MAX_TOTAL_BYTES = 20 * 1024 * 1024; +/** + * Grenzen fuer multer (Review WR-06): Dateien, Felder und Teile insgesamt sind begrenzt (nur das Feld + * `passwords` und die Dateien kommen vor), ein Textfeld hoechstens 256 KiB (30 Passwoerter zu je 1024 + * Zeichen, auch mit JSON-Maskierung weit darunter). Die Gesamtsumme der Dateien prueft der Speicher + * schon beim Empfang (cert-upload.ts). + */ +export const CERT_UPLOAD_LIMITS = { + fileSize: CERT_MAX_FILE_BYTES, + files: CERT_MAX_FILES, + fields: 5, + parts: CERT_MAX_FILES + 5, + fieldSize: 256 * 1024, +}; + /** Das Feld `passwords`: ein JSON-Array von Zeichenketten, je Datei eines (Task 4). */ export const CERT_MAX_PASSWORDS = 30; export const CERT_MAX_PASSWORD_CHARS = 1024; @@ -71,7 +86,10 @@ export class CertManagerController { @Post('analyze') @HttpCode(200) @UseInterceptors( - FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }), + FilesInterceptor('files', CERT_MAX_FILES, { + storage: createTotalLimitedStorage(CERT_MAX_TOTAL_BYTES), + limits: CERT_UPLOAD_LIMITS, + }), ) analyze( @UploadedFiles() files: UploadedFileLike[] | undefined, diff --git a/apps/api/src/cert-manager/cert-model.spec.ts b/apps/api/src/cert-manager/cert-model.spec.ts index e3d259d..0d537e4 100644 --- a/apps/api/src/cert-manager/cert-model.spec.ts +++ b/apps/api/src/cert-manager/cert-model.spec.ts @@ -209,3 +209,68 @@ describe('detectBlob: ZIP', () => { expect(r.ignored).toEqual([{ file: 0, path: 'leer.zip', reason: 'unknown' }]); }); }); + +describe('detectBlob: viele BEGIN-Zeilen (Review CR-02)', () => { + it('5 MiB BEGIN-Zeilen ohne END werden in weit unter 100 ms verarbeitet', () => { + const line = '-----BEGIN CERTIFICATE-----\n'; + const blob = Buffer.from(line.repeat(Math.ceil((5 * 1024 * 1024) / line.length))); + let best = Number.POSITIVE_INFINITY; + for (let i = 0; i < 3; i++) { + const started = performance.now(); + const r = detectBlob(blob, ctx('bomb.pem')); + best = Math.min(best, performance.now() - started); + expect(r.items).toEqual([]); + } + expect(best).toBeLessThan(100); + }); + + it('ein echtes Zertifikat hinter vielen unvollstaendigen BEGIN-Zeilen wird gefunden', () => { + const junk = '-----BEGIN BROKEN-----\n'.repeat(20_000); + const blob = Buffer.concat([Buffer.from(junk), fx('rsa-leaf.pem')]); + const started = performance.now(); + const r = detectBlob(blob, ctx('mix.pem')); + expect(performance.now() - started).toBeLessThan(500); + expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(1); + }); +}); + +describe('detectBlob: selbstsigniert ohne basicConstraints (Review WR-07)', () => { + const one = (name: string): CertItem => { + const found = certs(name); + expect(found).toHaveLength(1); + return found[0]; + }; + + it('Version 1 selbstsigniert (aeltere Wurzel): Wurzel und CA', () => { + const root = one('selfsigned-v1-root.pem'); + expect(new X509Certificate(fx('selfsigned-v1-root.pem')).ca).toBe(false); // Node allein sieht keine CA + expect(root.selfSigned).toBe(true); + expect(root.isCa).toBe(true); + expect(root.role).toBe('root'); + }); + + it('Version 3 ohne basicConstraints, aber mit keyCertSign: Wurzel', () => { + const root = one('selfsigned-nobc-ca.pem'); + expect(root.isCa).toBe(true); + expect(root.role).toBe('root'); + }); + + it('Version 3 ohne basicConstraints, nur Server-Schluesselverwendung: bleibt Serverzertifikat', () => { + const leaf = one('selfsigned-nobc-leaf.pem'); + expect(leaf.selfSigned).toBe(true); + expect(leaf.isCa).toBe(false); + expect(leaf.role).toBe('end-entity'); + }); + + it('mit basicConstraints cA:FALSE entscheidet allein diese Angabe', () => { + const leaf = one('selfsigned-leaf.pem'); + expect(leaf.role).toBe('end-entity'); + expect(leaf.isCa).toBe(false); + }); + + it('bestehende Rollen bleiben: Wurzel, Zwischenzertifikat, Serverzertifikat', () => { + expect(one('rsa-root.pem').role).toBe('root'); + expect(one('rsa-inter.pem').role).toBe('intermediate'); + expect(one('rsa-leaf.pem').role).toBe('end-entity'); + }); +}); diff --git a/apps/api/src/cert-manager/cert-model.ts b/apps/api/src/cert-manager/cert-model.ts index 54b111d..2ef28c5 100644 --- a/apps/api/src/cert-manager/cert-model.ts +++ b/apps/api/src/cert-manager/cert-model.ts @@ -1,5 +1,7 @@ import { X509Certificate } from 'node:crypto'; import * as forge from 'node-forge'; +import { acceptableIssuerUrls } from './cert-aia-url'; +import { type RequestBudget, rethrowRequestError } from './cert-budget'; import { csrItemFromDer } from './cert-csr'; import { describeKey, @@ -15,10 +17,12 @@ import type { AnyItem, CertItem, CertRole, + CsrItem, IgnoredEntry, ItemSource, LockedEntry, } from './cert-types'; +import { scanPemBlocks } from './pem-scan'; import { expandZip, isZip } from './zip-expand'; // Die Schluesselhelfer wohnen in cert-keys.ts (dort ohne Importschleife nutzbar); hier weiter erreichbar. @@ -47,6 +51,8 @@ export interface DetectContext { passwords: string[]; /** Das fuer genau diese Datei eingegebene Passwort; leer, wenn keines. Nur fuer „gesperrt: Passwort falsch“. */ ownPassword?: string; + /** Grenzen der Anfrage (Anzahl, Rechenaufwand); ohne Angabe gelten sie nicht */ + budget?: RequestBudget; } export interface DetectResult { @@ -58,7 +64,6 @@ export interface DetectResult { const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']); const PKCS7_LABELS = new Set(['PKCS7', 'CMS']); const CSR_LABELS = new Set(['CERTIFICATE REQUEST', 'NEW CERTIFICATE REQUEST']); -const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g; const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/; function firstValue(v: unknown): string { @@ -82,20 +87,8 @@ function sanList(subjectAltName: string | undefined): string[] { } function aiaUrls(legacyInfoAccess: unknown): string[] { - const raw = (legacyInfoAccess as Record | undefined)?.['CA Issuers - URI']; - const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []; - const urls: string[] = []; - for (const entry of list) { - if (typeof entry !== 'string') continue; - try { - const u = new URL(entry); - if (u.protocol === 'http:' || u.protocol === 'https:') urls.push(entry); - } catch { - // keine gueltige Adresse: ueberspringen - } - if (urls.length >= 5) break; - } - return urls; + // Gleicher Filter wie beim Abruf (cert-aia-url.ts): der Knopf erscheint nur, wenn ein Abruf moeglich ist. + return acceptableIssuerUrls(legacyInfoAccess).map((url) => url.href); } /** @@ -112,6 +105,73 @@ function isSelfSigned(x: X509Certificate): boolean { } } +const OID_BASIC_CONSTRAINTS = '2.5.29.19'; +const OID_KEY_USAGE = '2.5.29.15'; +/** keyCertSign ist Bit 5 der Schluesselverwendung (RFC 5280); im ersten Byte ist das 0x04. */ +const KEY_CERT_SIGN_MASK = 0x04; + +interface CaHints { + /** Erweiterung basicConstraints vorhanden (dann entscheidet allein ihr cA-Wert) */ + hasBasicConstraints: boolean; + /** Zertifikat der Version 1 (keine Erweiterungen) */ + isV1: boolean; + /** Schluesselverwendung nennt keyCertSign */ + keyCertSign: boolean; +} + +/** + * Liest Version, basicConstraints und Schluesselverwendung aus dem DER (reiner ASN.1-Lauf; Node + * nennt `ca` nur aus basicConstraints und gibt die Schluesselverwendung nicht heraus). Nicht + * lesbar: alles falsch, das Zertifikat bleibt, was `x.ca` sagt. + */ +function caHints(raw: Buffer): CaHints { + const none: CaHints = { hasBasicConstraints: false, isV1: false, keyCertSign: false }; + try { + const options = { decodeBitStrings: false } as unknown as boolean; + const root = forge.asn1.fromDer(forge.util.createBuffer(raw.toString('binary')), options); + const tbs = (root.value as forge.asn1.Asn1[])[0]; + const parts = tbs.value as forge.asn1.Asn1[]; + const isV1 = !(parts[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && parts[0].type === 0); + const extensionsHolder = parts.find( + (p) => p.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && p.type === 3, + ); + const hints: CaHints = { ...none, isV1 }; + const extensions = (extensionsHolder?.value as forge.asn1.Asn1[] | undefined)?.[0]?.value as + | forge.asn1.Asn1[] + | undefined; + for (const extension of extensions ?? []) { + const fields = extension.value as forge.asn1.Asn1[]; + const oid = forge.asn1.derToOid(fields[0].value as string); + if (oid === OID_BASIC_CONSTRAINTS) hints.hasBasicConstraints = true; + if (oid === OID_KEY_USAGE) { + // OCTET STRING mit einer BIT STRING: Tag, Laenge, Anzahl ungenutzter Bits, dann die Bits + const octets = fields[fields.length - 1].value as string; + const bits = Buffer.from(octets, 'binary'); + if (bits.length >= 4 && bits[0] === 0x03) { + hints.keyCertSign = (bits[3] & KEY_CERT_SIGN_MASK) !== 0; + } + } + } + return hints; + } catch { + return none; + } +} + +/** + * Ist das Zertifikat eine CA? Wie OpenSSLs X509_check_ca: mit basicConstraints entscheidet cA; + * ohne diese Erweiterung zaehlt ein selbstsigniertes Zertifikat der Version 1 (aeltere Wurzeln) + * oder eines mit keyCertSign in der Schluesselverwendung als CA. Ein selbstsigniertes + * Serverzertifikat ohne basicConstraints (nur digitalSignature, keyEncipherment) bleibt ein + * Serverzertifikat (Review WR-07). + */ +function isCaCertificate(x: X509Certificate, selfSigned: boolean): boolean { + if (x.ca) return true; + const hints = caHints(x.raw); + if (hints.hasBasicConstraints) return false; + return (hints.isV1 && selfSigned) || hints.keyCertSign; +} + export function roleOf(isCa: boolean, selfSigned: boolean): CertRole { if (!isCa) return 'end-entity'; return selfSigned ? 'root' : 'intermediate'; @@ -127,7 +187,7 @@ export function certItemFromDer(der: Buffer, source: ItemSource): CertItem { }; const cn = firstValue(legacy.subject?.CN); const selfSigned = isSelfSigned(x); - const isCa = x.ca; + const isCa = isCaCertificate(x, selfSigned); const role = roleOf(isCa, selfSigned); const notAfter = x.validToDate; const key = describeKey(x.publicKey); @@ -268,9 +328,8 @@ interface PemBlock { function pemBlocks(text: string): PemBlock[] { const blocks: PemBlock[] = []; - for (const m of text.matchAll(PEM_BLOCK)) { - const label = m[1]; - const rawBody = m[2]; + for (const m of scanPemBlocks(text)) { + const rawBody = m.body; const encrypted = /Proc-Type:\s*4,\s*ENCRYPTED/i.test(rawBody); const body = rawBody .split(/\r?\n/) @@ -278,7 +337,12 @@ function pemBlocks(text: string): PemBlock[] { .join('') .replace(/\s+/g, ''); const valid = BASE64_BODY.test(body) && body.length % 4 === 0; - blocks.push({ label, text: m[0], der: valid ? Buffer.from(body, 'base64') : null, encrypted }); + blocks.push({ + label: m.label, + text: m.text, + der: valid ? Buffer.from(body, 'base64') : null, + encrypted, + }); } return blocks; } @@ -286,11 +350,14 @@ function pemBlocks(text: string): PemBlock[] { function certFromDer(der: Buffer, ctx: DetectContext): CertItem | null { const seq = leadingDerSequence(der); if (!seq) return null; + let item: CertItem; try { - return certItemFromDer(seq, { file: ctx.file, path: ctx.path }); + item = certItemFromDer(seq, { file: ctx.file, path: ctx.path }); } catch { return null; } + ctx.budget?.spendCert(); + return item; } function mergeInto(result: DetectResult, part: DetectResult | null): void { @@ -313,7 +380,8 @@ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null { result, keyFromPemBlock(block.text, block.label, block.encrypted, keyContext(ctx)), ); - } catch { + } catch (error) { + rethrowRequestError(error); // kaputter Schluesselblock: die anderen Bloecke der Datei bleiben gueltig } continue; @@ -326,16 +394,22 @@ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null { if (PKCS7_LABELS.has(block.label)) { try { result.items.push(...pkcs7Items(block.der, ctx)); - } catch { + } catch (error) { + rethrowRequestError(error); // kaputter PKCS#7-Block: die anderen Bloecke der Datei bleiben gueltig } } if (CSR_LABELS.has(block.label)) { + let csr: CsrItem | null = null; try { - result.items.push(csrItemFromDer(block.der, { file: ctx.file, path: ctx.path })); + csr = csrItemFromDer(block.der, { file: ctx.file, path: ctx.path }); } catch { // keine lesbare Anfrage: ueberspringen } + if (csr) { + ctx.budget?.spendCsr(); + result.items.push(csr); + } } } const empty = @@ -356,6 +430,7 @@ function keyContext(ctx: DetectContext) { path: ctx.path, passwords: ctx.passwords, ownPassword: ctx.ownPassword, + budget: ctx.budget, }; } @@ -365,8 +440,15 @@ function keyContext(ctx: DetectContext) { */ function detectPkcs12(blob: Buffer, ctx: DetectContext): DetectResult | null { if (!isPkcs12Der(blob)) return null; - const read = readPkcs12(blob, ctx.passwords, ctx.ownPassword ?? ''); + const read = readPkcs12(blob, ctx.passwords, ctx.ownPassword ?? '', ctx.budget); if (!read.ok) { + if (read.reason === 'tooExpensive') { + return { + items: [], + ignored: [{ file: ctx.file, path: ctx.path, reason: 'protectionTooExpensive' }], + locked: [], + }; + } return { items: [], ignored: [], @@ -379,6 +461,7 @@ function detectPkcs12(blob: Buffer, ctx: DetectContext): DetectResult | null { if (item) result.items.push(item); } for (const { key, shrouded } of read.contents.keys) { + ctx.budget?.spendKey(); result.items.push(keyItemFromObject(key, { file: ctx.file, path: ctx.path }, shrouded)); } return result.items.length > 0 ? result : null; @@ -397,11 +480,9 @@ function detectPrivateKey(blob: Buffer, ctx: DetectContext): DetectResult | null /** Zertifikatsanfrage als DER. */ function detectCsr(blob: Buffer, ctx: DetectContext): DetectResult | null { - return { - items: [csrItemFromDer(blob, { file: ctx.file, path: ctx.path })], - ignored: [], - locked: [], - }; + const csr = csrItemFromDer(blob, { file: ctx.file, path: ctx.path }); + ctx.budget?.spendCsr(); + return { items: [csr], ignored: [], locked: [] }; } const STAGES: ((blob: Buffer, ctx: DetectContext) => DetectResult | null)[] = [ @@ -423,7 +504,8 @@ export function detectBlob(blob: Buffer, ctx: DetectContext): DetectResult { try { const result = stage(blob, ctx); if (result) return result; - } catch { + } catch (error) { + rethrowRequestError(error); // diese Stufe kann den Inhalt nicht lesen: naechste Stufe versuchen } } diff --git a/apps/api/src/cert-manager/cert-pkcs12.spec.ts b/apps/api/src/cert-manager/cert-pkcs12.spec.ts index 7eadd20..6bd9dea 100644 --- a/apps/api/src/cert-manager/cert-pkcs12.spec.ts +++ b/apps/api/src/cert-manager/cert-pkcs12.spec.ts @@ -1,8 +1,9 @@ -import { createPrivateKey, X509Certificate } from 'node:crypto'; +import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import * as forge from 'node-forge'; import { describe, expect, it, vi } from 'vitest'; +import { MAX_KDF_ITERATIONS, RequestBudget } from './cert-budget'; import { candidatePasswords, keyIdOf } from './cert-keys'; import { detectBlob } from './cert-model'; import { isPkcs12Der, readPkcs12, writePkcs12 } from './cert-pkcs12'; @@ -292,3 +293,159 @@ describe('writePkcs12', () => { expect(der.includes(Buffer.from(NEW_PASSWORD))).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Review CR-03: Passwoerter mit Umlauten und Eurozeichen (UTF-8 wie OpenSSL 3, Windows, Java) +// --------------------------------------------------------------------------- + +/** Die drei Ableitungsfunktionen von forge, die readPkcs12 und writePkcs12 zeitweise austauschen. */ +function forgeKdf(): unknown[] { + const pbe = (forge.pki as unknown as { pbe: Record }).pbe; + return [forge.pkcs5.pbkdf2, pbe.generatePkcs12Key, forge.pkcs12.generateKey]; +} + +const UMLAUT_PASSWORDS = [ + ['pässwörd', 'umlaut'], + ['pw€', 'euro'], +] as const; + +describe('PKCS#12 mit Umlaut- und Euro-Passwoertern (OpenSSL-Dateien lesen)', () => { + const files = UMLAUT_PASSWORDS.flatMap(([password, name]) => + (['modern', 'compat'] as const).map( + (profile) => [`rsa-${name}-${profile}.pfx`, password] as const, + ), + ); + + it.each(files)('%s: das richtige Passwort oeffnet die Datei', (file, password) => { + const result = readPkcs12(fx(file), [password], password); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.contents.certDers).toHaveLength(3); + expect(result.contents.keys).toHaveLength(1); + expect(result.contents.keys[0].key.asymmetricKeyType).toBe('rsa'); + }); + + it.each(files)('%s: ein falsches Passwort bleibt passwordWrong', (file) => { + expect(readPkcs12(fx(file), ['passwoerd'], 'passwoerd')).toEqual({ + ok: false, + reason: 'passwordWrong', + }); + }); + + it('forge ist nach dem Lesen unveraendert (PBKDF2 und PKCS#12-Ableitung)', () => { + const before = forgeKdf(); + readPkcs12(fx('rsa-umlaut-modern.pfx'), ['pässwörd'], 'pässwörd'); + readPkcs12(fx('rsa-umlaut-modern.pfx'), ['falsch'], 'falsch'); + expect(forgeKdf()).toEqual(before); + }); +}); + +/** Der verschluesselte Schluessel (EncryptedPrivateKeyInfo) aus einem PFX, ohne forges Entschluesselung. */ +function shroudedKeyDer(pfx: Buffer): Buffer { + const OID_SHROUDED = '1.2.840.113549.1.12.10.1.2'; + const root = forge.asn1.fromDer(forge.util.createBuffer(pfx.toString('binary'))); + const authSafeContent = (root.value as forge.asn1.Asn1[])[1]; + const octets = ((authSafeContent.value as forge.asn1.Asn1[])[1].value as forge.asn1.Asn1[])[0]; + const authSafe = forge.asn1.fromDer(forge.util.createBuffer(octets.value as string)); + for (const info of authSafe.value as forge.asn1.Asn1[]) { + const wrapped = ((info.value as forge.asn1.Asn1[])[1].value as forge.asn1.Asn1[])[0]; + if (typeof wrapped.value !== 'string') continue; // verschluesselte Zertifikate + const safeContents = forge.asn1.fromDer(forge.util.createBuffer(wrapped.value)); + for (const bag of safeContents.value as forge.asn1.Asn1[]) { + const [oid, content] = bag.value as forge.asn1.Asn1[]; + if (forge.asn1.derToOid(oid.value as string) === OID_SHROUDED) { + const encrypted = (content.value as forge.asn1.Asn1[])[0]; + return Buffer.from(forge.asn1.toDer(encrypted).getBytes(), 'binary'); + } + } + } + throw new Error('no shrouded key bag'); +} + +describe('writePkcs12 mit Umlaut- und Euro-Passwoertern (fuer OpenSSL, Windows und Java lesbar)', () => { + const key = createPrivateKey(fx('rsa-leaf-key.pem')); + const certs = ['rsa-leaf.pem', 'rsa-inter.pem'].map((f) => new X509Certificate(fx(f)).raw); + + it.each( + UMLAUT_PASSWORDS.flatMap( + ([password]) => + [ + ['modern', password], + ['compat', password], + ] as const, + ), + )('%s mit %s: der Schluessel oeffnet sich mit UTF-8 (wie OpenSSL) und das Passwort ist lesbar', (profile, password) => { + const pfx = writePkcs12({ + keyObject: key, + certDers: certs, + password, + profile, + friendlyName: 'server', + }); + // Unabhaengig von forge: node:crypto leitet PBES2 aus den UTF-8-Bytes ab (wie OpenSSL 3). + if (profile === 'modern') { + const opened = createPrivateKey({ + key: shroudedKeyDer(pfx), + format: 'der', + type: 'pkcs8', + passphrase: password, + }); + expect(keyIdOf(createPublicKey(opened))).toBe(keyIdOf(createPublicKey(key))); + } + const back = readPkcs12(pfx, [password], password); + expect(back.ok).toBe(true); + if (back.ok) expect(back.contents.keys).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Review WR-04: Obergrenze fuer Ableitungsrunden +// --------------------------------------------------------------------------- + +describe('PKCS#12: zu aufwendiger Passwortschutz', () => { + it('2 000 000 Runden: tooExpensive, schnell und ohne zu rechnen', () => { + const started = performance.now(); + const result = readPkcs12(fx('rsa-expensive.pfx'), ['Test-Pass-123'], 'Test-Pass-123'); + expect(result).toEqual({ ok: false, reason: 'tooExpensive' }); + expect(performance.now() - started).toBeLessThan(1000); + }); + + it('detectBlob meldet protectionTooExpensive statt gesperrt', () => { + const r = detectBlob(fx('rsa-expensive.pfx'), { + file: 0, + path: 'teuer.pfx', + passwords: ['Test-Pass-123'], + ownPassword: 'Test-Pass-123', + }); + expect(r.items).toEqual([]); + expect(r.locked).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'teuer.pfx', reason: 'protectionTooExpensive' }]); + }); + + it('die Grenze steht bei einer Million Runden', () => { + expect(MAX_KDF_ITERATIONS).toBe(1_000_000); + const budget = new RequestBudget(); + expect(() => budget.spendKdf(1_000_000)).not.toThrow(); + expect(() => new RequestBudget().spendKdf(1_000_001)).toThrow(); + }); + + it('die Summe je Anfrage ist begrenzt (viele Versuche mit hoher, erlaubter Rundenzahl)', () => { + const budget = new RequestBudget(); + let accepted = 0; + try { + for (let i = 0; i < 100; i++) { + budget.spendKdf(600_000); + accepted++; + } + } catch { + // erwartet + } + expect(accepted).toBe(10); + }); + + it('forge ist nach einer abgelehnten Datei unveraendert', () => { + const before = forgeKdf(); + readPkcs12(fx('rsa-expensive.pfx'), ['Test-Pass-123'], 'Test-Pass-123'); + expect(forgeKdf()).toEqual(before); + }); +}); diff --git a/apps/api/src/cert-manager/cert-pkcs12.ts b/apps/api/src/cert-manager/cert-pkcs12.ts index 9582e00..f497fed 100644 --- a/apps/api/src/cert-manager/cert-pkcs12.ts +++ b/apps/api/src/cert-manager/cert-pkcs12.ts @@ -1,5 +1,6 @@ -import { createPrivateKey, type KeyObject } from 'node:crypto'; +import { createPrivateKey, type KeyObject, pbkdf2Sync } from 'node:crypto'; import * as forge from 'node-forge'; +import { KdfTooExpensiveError, RequestBudget } from './cert-budget'; import { MAX_PASSWORDS } from './cert-keys'; /** @@ -26,7 +27,76 @@ export interface Pkcs12Contents { export type Pkcs12Read = | { ok: true; contents: Pkcs12Contents } - | { ok: false; reason: 'passwordNeeded' | 'passwordWrong' }; + | { ok: false; reason: 'passwordNeeded' | 'passwordWrong' | 'tooExpensive' }; + +/** Namen der PRF fuer node:crypto aus dem, was forge uebergibt (Zeichenkette oder md-Objekt). */ +function prfName(md: unknown): string { + if (typeof md === 'string') return md; + const algorithm = (md as { algorithm?: unknown } | null | undefined)?.algorithm; + return typeof algorithm === 'string' ? algorithm : 'sha1'; +} + +/** + * Fuehrt `fn` mit zwei Eingriffen in forges Schluesselableitung aus (Review CR-03, WR-04). Alles + * laeuft synchron; die Originale stehen im `finally` wieder an ihrem Platz (gleiche Begruendung + * wie bei `writePkcs12`: Node ist einfaedig, kein anderer Aufrufer sieht den Austausch). + * + * 1. PBKDF2 (PBES2, AES): forge nimmt das Passwort als JS-Zeichenkette und macht daraus ein Byte + * je UTF-16-Einheit. OpenSSL 3, Windows und Java nehmen die UTF-8-Bytes; bei Umlauten oder dem + * Eurozeichen waere ein Container sonst fuer jedes andere Programm unlesbar und umgekehrt. + * Hier wird mit node:crypto aus den UTF-8-Bytes abgeleitet (auch SHA-256/512 nativ statt in + * reinem JavaScript). Die PKCS#12-eigene Ableitung (3DES, RC2, MAC) bleibt wie sie ist: sie + * nimmt BMPString (UTF-16) aus den Zeichen, genau wie OpenSSL. + * 2. Aufwandsgrenze: jede Ableitung meldet ihre Rundenzahl an `budget`, bevor sie rechnet; zu viele + * Runden (oder eine aufgebrauchte Anfragegrenze) brechen vorher ab. + */ +function withForgeKdf(budget: RequestBudget, fn: () => T, onExpensive?: () => void): T { + const pkcs5 = forge.pkcs5 as unknown as Record; + const pbe = (forge.pki as unknown as { pbe: Record }).pbe; + const p12 = forge.pkcs12 as unknown as Record; + const original = { + pbkdf2: pkcs5.pbkdf2, + generatePkcs12Key: pbe.generatePkcs12Key, + generateKey: p12.generateKey, + }; + const spend = (iterations: number): void => { + try { + budget.spendKdf(iterations); + } catch (error) { + onExpensive?.(); + throw error; + } + }; + try { + pkcs5.pbkdf2 = ( + password: string, + salt: string, + iterations: number, + keyLength: number, + md?: unknown, + ): string => { + spend(iterations); + return pbkdf2Sync( + Buffer.from(password, 'utf8'), + Buffer.from(salt, 'binary'), + iterations, + keyLength, + prfName(md), + ).toString('binary'); + }; + const guardedKdf = (...args: unknown[]): unknown => { + spend(args[3] as number); + return (original.generatePkcs12Key as (...a: unknown[]) => unknown)(...args); + }; + pbe.generatePkcs12Key = guardedKdf; + p12.generateKey = guardedKdf; + return fn(); + } finally { + pkcs5.pbkdf2 = original.pbkdf2; + pbe.generatePkcs12Key = original.generatePkcs12Key; + p12.generateKey = original.generateKey; + } +} function parseAsn1(der: Buffer): forge.asn1.Asn1 { // Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt. @@ -98,9 +168,15 @@ function extract(p12: forge.pkcs12.Pkcs12Pfx): Pkcs12Contents { * Oeffnet einen PKCS#12-Container. Probiert das Passwort der Datei (`ownPassword`), das leere * Passwort und danach die uebrigen `passwords`, zusammen hoechstens MAX_PASSWORDS + 1 Versuche. * Ohne passendes Passwort: gesperrt; `passwordWrong`, wenn die Datei ein eigenes Passwort hatte. + * Zu viele Ableitungsrunden (WR-04): `tooExpensive`, bevor irgendetwas entschluesselt wird. * Ein Container, den forge aus anderem Grund nicht lesen kann, wirft (der Aufrufer meldet „unbekannt“). */ -export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''): Pkcs12Read { +export function readPkcs12( + der: Buffer, + passwords: string[], + ownPassword = '', + budget: RequestBudget = new RequestBudget(), +): Pkcs12Read { const asn1 = parseAsn1(der); const tries: string[] = []; for (const p of [ownPassword, '', ...passwords]) { @@ -108,10 +184,22 @@ export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''): } let passwordProblem = false; let lastError: unknown = new Error('unreadable PKCS#12'); + let expensive = false; for (const password of tries) { try { - return { ok: true, contents: extract(forge.pkcs12.pkcs12FromAsn1(asn1, false, password)) }; + const p12 = withForgeKdf( + budget, + () => forge.pkcs12.pkcs12FromAsn1(asn1, false, password), + () => { + expensive = true; + }, + ); + return { ok: true, contents: extract(p12) }; } catch (error) { + // forge verpackt Fehler aus der Ableitung teils neu: darum zaehlt die Markierung, nicht der Typ. + if (expensive || error instanceof KdfTooExpensiveError) { + return { ok: false, reason: 'tooExpensive' }; + } lastError = error; const message = error instanceof Error ? error.message : ''; if (/mac|password|decrypt|padding|invalid|asn\.?1|too few bytes/i.test(message)) { @@ -177,15 +265,17 @@ export function writePkcs12(options: WritePkcs12Options): Buffer { pki.privateKeyToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1; pki.wrapRsaPrivateKey = (asn1: forge.asn1.Asn1) => asn1; pki.certificateToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1; - const p12 = forge.pkcs12.toPkcs12Asn1( - (keyAsn1 ? { asn1: keyAsn1 } : null) as unknown as forge.pki.rsa.PrivateKey | null, - certs as unknown as forge.pki.Certificate[], - options.password, - { - algorithm: options.profile === 'modern' ? 'aes256' : '3des', - friendlyName: bmpString(options.friendlyName), - generateLocalKeyId: true, - }, + const p12 = withForgeKdf(new RequestBudget(), () => + forge.pkcs12.toPkcs12Asn1( + (keyAsn1 ? { asn1: keyAsn1 } : null) as unknown as forge.pki.rsa.PrivateKey | null, + certs as unknown as forge.pki.Certificate[], + options.password, + { + algorithm: options.profile === 'modern' ? 'aes256' : '3des', + friendlyName: bmpString(options.friendlyName), + generateLocalKeyId: true, + }, + ), ); return toBuffer(p12); } finally { diff --git a/apps/api/src/cert-manager/cert-types.ts b/apps/api/src/cert-manager/cert-types.ts index 0891fee..ee5bef8 100644 --- a/apps/api/src/cert-manager/cert-types.ts +++ b/apps/api/src/cert-manager/cert-types.ts @@ -40,7 +40,7 @@ export interface CertItem { sha1: string; isCa: boolean; selfSigned: boolean; - /** nur http/https, hoechstens 5 */ + /** nur abrufbare Adressen (http/https, Standardport, ohne Zugangsdaten), hoechstens 3 */ aiaIssuerUrls: string[]; /** Kennung des oeffentlichen Schluessels: 'k-' + 16 Hex von sha256(SPKI-DER), passt zu KeyItem.id und CsrItem.keyId */ keyId: string | null; @@ -114,7 +114,8 @@ export type IgnoredReason = | 'suspicious' | 'zipTooLarge' | 'tooManyEntries' - | 'unsupportedKey'; + | 'unsupportedKey' + | 'protectionTooExpensive'; export interface IgnoredEntry { file: number; @@ -179,7 +180,9 @@ export type CertErrorCode = | 'formatNotPossible' | 'templateNeedsKey' | 'tooLarge' + | 'tooManyItems' | 'aiaMissing' + | 'aiaNotAllowed' | 'aiaInternal' | 'aiaNotIssuer' | 'aiaUnreachable' diff --git a/apps/api/src/cert-manager/cert-upload.spec.ts b/apps/api/src/cert-manager/cert-upload.spec.ts new file mode 100644 index 0000000..111a604 --- /dev/null +++ b/apps/api/src/cert-manager/cert-upload.spec.ts @@ -0,0 +1,80 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { CERT_UPLOAD_LIMITS } from './cert-manager.controller'; +import { createTotalLimitedStorage } from './cert-upload'; + +type Result = { error: unknown; info?: { buffer: Buffer; size: number } }; + +function handle( + storage: ReturnType, + req: object, + chunks: Buffer[], +): Promise { + return new Promise((resolve) => { + const stream = new PassThrough(); + storage._handleFile(req, { stream }, (error, info) => resolve({ error, info })); + for (const chunk of chunks) stream.write(chunk); + stream.end(); + }); +} + +describe('createTotalLimitedStorage (Review WR-06)', () => { + it('sammelt eine Datei wie multers Speicher (buffer und size)', async () => { + const storage = createTotalLimitedStorage(100); + const r = await handle(storage, {}, [Buffer.from('ab'), Buffer.from('cd')]); + expect(r.error).toBeNull(); + expect(r.info?.buffer.toString()).toBe('abcd'); + expect(r.info?.size).toBe(4); + }); + + it('die Summe mehrerer Dateien einer Anfrage zaehlt: die Datei, die die Grenze reisst, bricht ab', async () => { + const storage = createTotalLimitedStorage(10); + const req = {}; + const first = await handle(storage, req, [Buffer.alloc(6)]); + expect(first.error).toBeNull(); + const second = await handle(storage, req, [Buffer.alloc(3), Buffer.alloc(3)]); + const e = second.error as { getStatus(): number; getResponse(): { code: string } }; + expect(e.getStatus()).toBe(413); + expect(e.getResponse().code).toBe('tooLarge'); + expect(second.info).toBeUndefined(); + }); + + it('meldet den Fehler genau einmal, auch wenn danach noch Daten eintreffen', async () => { + const storage = createTotalLimitedStorage(4); + let calls = 0; + await new Promise((resolve) => { + const stream = new PassThrough(); + storage._handleFile({}, { stream }, () => { + calls++; + }); + stream.write(Buffer.alloc(10)); + stream.write(Buffer.alloc(10)); + stream.end(); + stream.on('end', () => resolve()); + stream.resume(); + }); + expect(calls).toBe(1); + }); + + it('verschiedene Anfragen zaehlen getrennt', async () => { + const storage = createTotalLimitedStorage(10); + expect((await handle(storage, {}, [Buffer.alloc(9)])).error).toBeNull(); + expect((await handle(storage, {}, [Buffer.alloc(9)])).error).toBeNull(); + }); + + it('_removeFile entfernt den Puffer', () => { + const storage = createTotalLimitedStorage(10); + const file = { stream: new PassThrough(), buffer: Buffer.from('x') }; + storage._removeFile({}, file, () => undefined); + expect(file.buffer).toBeUndefined(); + }); +}); + +describe('Upload-Grenzen der Route analyze', () => { + it('Dateien, Felder und Teile sind begrenzt', () => { + expect(CERT_UPLOAD_LIMITS).toMatchObject({ fileSize: 5 * 1024 * 1024, files: 30 }); + expect(CERT_UPLOAD_LIMITS.parts).toBeGreaterThanOrEqual(30); + expect(CERT_UPLOAD_LIMITS.parts).toBeLessThanOrEqual(40); + expect(CERT_UPLOAD_LIMITS.fields).toBeLessThanOrEqual(5); + }); +}); diff --git a/apps/api/src/cert-manager/cert-upload.ts b/apps/api/src/cert-manager/cert-upload.ts new file mode 100644 index 0000000..bf47c8e --- /dev/null +++ b/apps/api/src/cert-manager/cert-upload.ts @@ -0,0 +1,71 @@ +import { PayloadTooLargeException } from '@nestjs/common'; + +/** + * Speicher fuer hochgeladene Dateien mit Gesamtgrenze beim Empfang (Review WR-06). + * + * multer mit Standard-Speicher haelt jede Datei komplett im Arbeitsspeicher; die Grenze je Datei + * (5 MiB) gilt, die Gesamtsumme wurde aber erst geprueft, nachdem alle 30 Dateien angekommen waren + * (bis zu 150 MiB). Dieser Speicher zaehlt mit, waehrend die Daten eintreffen, und bricht die + * Anfrage mit 413 `tooLarge` ab, sobald die Summe die Grenze ueberschreitet; alles bis dahin + * Gesammelte wird verworfen. + * + * Die Form entspricht multers Speicher-Schnittstelle (`_handleFile`, `_removeFile`); der Speicher + * liefert wie multer `buffer` und `size` an der Datei. Es gibt kein @types/multer im Projekt. + */ + +interface IncomingFile { + stream: NodeJS.ReadableStream; + buffer?: Buffer; +} + +type Callback = (error: unknown, info?: { buffer: Buffer; size: number }) => void; + +export interface TotalLimitedStorage { + _handleFile(req: object, file: IncomingFile, cb: Callback): void; + _removeFile(req: object, file: IncomingFile, cb: (error: null) => void): void; +} + +export function createTotalLimitedStorage(maxTotalBytes: number): TotalLimitedStorage { + // Summe je Anfrage; faellt mit der Anfrage weg + const totals = new WeakMap(); + return { + _handleFile(req, file, cb) { + const chunks: Buffer[] = []; + let finished = false; + const finish: Callback = (error, info) => { + if (finished) return; + finished = true; + chunks.length = 0; + if (error) cb(error); + else cb(null, info); + }; + file.stream.on('data', (chunk: Buffer | string) => { + if (finished) return; // Rest der abgebrochenen Anfrage nur verwerfen + const part = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + const total = (totals.get(req) ?? 0) + part.length; + totals.set(req, total); + if (total > maxTotalBytes) { + finish( + new PayloadTooLargeException({ + code: 'tooLarge', + message: 'Files together exceed 20 MiB', + }), + ); + return; + } + chunks.push(part); + }); + file.stream.on('end', () => { + if (finished) return; + const buffer = Buffer.concat(chunks); + finished = true; + cb(null, { buffer, size: buffer.length }); + }); + file.stream.on('error', (error) => finish(error)); + }, + _removeFile(_req, file, cb) { + delete file.buffer; + cb(null); + }, + }; +} diff --git a/apps/api/src/cert-manager/pem-scan.spec.ts b/apps/api/src/cert-manager/pem-scan.spec.ts new file mode 100644 index 0000000..9c9d589 --- /dev/null +++ b/apps/api/src/cert-manager/pem-scan.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_PEM_BLOCKS, scanPemBlocks } from './pem-scan'; + +const BLOCK = '-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n'; + +describe('scanPemBlocks', () => { + it('liest Bloecke mit Etikett, Rumpf und Originaltext', () => { + const r = scanPemBlocks(`vorspann\n${BLOCK}zwischen\n${BLOCK}`); + expect(r).toHaveLength(2); + expect(r[0].label).toBe('CERTIFICATE'); + expect(r[0].body).toBe('\nQUJD\n'); + expect(r[0].text).toBe(BLOCK.trimEnd()); + }); + + it('liest verschiedene Etiketten und Kopfzeilen im Rumpf', () => { + const key = + '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n\nQUJD\n-----END RSA PRIVATE KEY-----'; + const r = scanPemBlocks(`${BLOCK}${key}\n`); + expect(r.map((b) => b.label)).toEqual(['CERTIFICATE', 'RSA PRIVATE KEY']); + expect(r[1].body).toContain('Proc-Type'); + }); + + it('END mit anderem Etikett schliesst nicht; BEGIN ohne END wird uebersprungen', () => { + const r = scanPemBlocks( + `-----BEGIN A-----\nx\n-----END B-----\n${BLOCK}-----BEGIN CERTIFICATE-----\nohne ende`, + ); + expect(r).toHaveLength(1); + expect(r[0].label).toBe('CERTIFICATE'); + }); + + it('verschachtelt wie bisher: das innere Paar gewinnt, wenn das aeussere kein END hat', () => { + const r = scanPemBlocks(`-----BEGIN A-----\n${BLOCK}`); + expect(r.map((b) => b.label)).toEqual(['CERTIFICATE']); + }); + + it('ungueltige Etiketten (Kleinbuchstaben, leer, zu lang) ergeben nichts', () => { + expect(scanPemBlocks('-----BEGIN cert-----\n-----END cert-----')).toEqual([]); + expect(scanPemBlocks('-----BEGIN -----\n-----END -----')).toEqual([]); + const long = 'A'.repeat(200); + expect(scanPemBlocks(`-----BEGIN ${long}-----\n-----END ${long}-----`)).toEqual([]); + }); + + it('begrenzt die Blockzahl', () => { + const many = BLOCK.repeat(MAX_PEM_BLOCKS + 50); + expect(scanPemBlocks(many)).toHaveLength(MAX_PEM_BLOCKS); + }); +}); + +describe('scanPemBlocks: Laufzeit (Review CR-02)', () => { + const MIB5 = 5 * 1024 * 1024; + + /** Beste von drei Messungen: unabhaengig von Lastspitzen paralleler Testlaeufe (alt: Minuten). */ + function timed(text: string): number { + let best = Number.POSITIVE_INFINITY; + for (let i = 0; i < 3; i++) { + const started = performance.now(); + scanPemBlocks(text); + best = Math.min(best, performance.now() - started); + } + return best; + } + + it('5 MiB gleiche BEGIN-Zeilen ohne END: weit unter 100 ms', () => { + const line = '-----BEGIN A-----\n'; + const text = line.repeat(Math.ceil(MIB5 / line.length)); + expect(timed(text)).toBeLessThan(100); + }); + + it('5 MiB BEGIN-Zeilen mit lauter verschiedenen Etiketten: weit unter 100 ms', () => { + const parts: string[] = []; + let size = 0; + for (let i = 0; size < MIB5; i++) { + const line = `-----BEGIN L${i}-----\n`; + parts.push(line); + size += line.length; + } + expect(timed(parts.join(''))).toBeLessThan(100); + }); + + it('5 MiB BEGIN-Zeilen und END-Zeilen mit anderem Etikett: unter einer Sekunde (alt: Minuten)', () => { + const pair = '-----BEGIN A-----\n-----END B-----\n'; + expect(timed(pair.repeat(Math.ceil(MIB5 / pair.length)))).toBeLessThan(1000); + }); + + it('5 MiB "-----BEGIN " ohne Etikett und "-----END " Wiederholungen: weit unter 100 ms', () => { + expect(timed('-----BEGIN '.repeat(Math.ceil(MIB5 / 11)))).toBeLessThan(100); + expect(timed(`${'-----END '.repeat(Math.ceil(MIB5 / 9))}-----BEGIN A-----`)).toBeLessThan(100); + }); +}); diff --git a/apps/api/src/cert-manager/pem-scan.ts b/apps/api/src/cert-manager/pem-scan.ts new file mode 100644 index 0000000..d96a0ca --- /dev/null +++ b/apps/api/src/cert-manager/pem-scan.ts @@ -0,0 +1,98 @@ +/** + * Linearer PEM-Block-Scanner (Review CR-02, quick-261009-ikt). + * + * Ersetzt die Regex `-----BEGIN (X)-----([\s\S]*?)-----END \1-----`: Bei jedem BEGIN ohne passendes + * END suchte die lazy Regex bis zum Dateiende, eine Datei aus lauter BEGIN-Zeilen kostete O(n^2) + * (400 KiB rund 5 s, 5 MiB rund 15 Minuten Stillstand des Node-Prozesses). + * + * Hier wird der Text hoechstens zweimal durchlaufen: einmal werden alle `-----END -----` + * Stellen je Etikett gesammelt, danach wandert ein Zeiger je Etikett vorwaerts. Ein BEGIN ohne + * passendes END kostet damit nur seine eigene Zeile. Die Block-Anzahl ist begrenzt. + */ + +export interface RawPemBlock { + label: string; + /** Inhalt zwischen den Zeilen BEGIN und END (Kopfzeilen und Base64) */ + body: string; + /** der ganze Block von BEGIN bis END, unveraendert */ + text: string; +} + +const BEGIN = '-----BEGIN '; +const END = '-----END '; +const DASHES = '-----'; +const MAX_LABEL_LENGTH = 64; + +/** Hoechstens so viele Bloecke je Text; mehr kommt nur von Missbrauch. */ +export const MAX_PEM_BLOCKS = 1000; + +function isLabelChar(code: number): boolean { + return ( + (code >= 65 && code <= 90) || // A-Z + (code >= 48 && code <= 57) || // 0-9 + code === 32 // Leerzeichen + ); +} + +/** Liest ein Etikett [A-Z0-9 ]+ ab `from`, gefolgt von "-----". null, wenn die Form nicht stimmt. */ +function readLabel(text: string, from: number): { label: string; end: number } | null { + let i = from; + const limit = Math.min(text.length, from + MAX_LABEL_LENGTH + 1); + while (i < limit && isLabelChar(text.charCodeAt(i))) i++; + if (i === from || i - from > MAX_LABEL_LENGTH) return null; + if (!text.startsWith(DASHES, i)) return null; + return { label: text.slice(from, i), end: i + DASHES.length }; +} + +export function scanPemBlocks(text: string, maxBlocks: number = MAX_PEM_BLOCKS): RawPemBlock[] { + const blocks: RawPemBlock[] = []; + if (!text.includes(BEGIN)) return blocks; + + // Erster Durchlauf: alle END-Zeilen je Etikett (Startpositionen, aufsteigend). + const ends = new Map(); + for (let at = text.indexOf(END); at !== -1; at = text.indexOf(END, at + END.length)) { + const parsed = readLabel(text, at + END.length); + if (!parsed) continue; + let entry = ends.get(parsed.label); + if (!entry) { + entry = { starts: [], ends: [], next: 0 }; + ends.set(parsed.label, entry); + } + entry.starts.push(at); + entry.ends.push(parsed.end); + } + + if (ends.size === 0) return blocks; + + // Zweiter Durchlauf: BEGIN-Zeilen in Textreihenfolge. + let pos = 0; + while (blocks.length < maxBlocks) { + const begin = text.indexOf(BEGIN, pos); + if (begin === -1) break; + const header = readLabel(text, begin + BEGIN.length); + if (!header) { + pos = begin + BEGIN.length; + continue; + } + const entry = ends.get(header.label); + if (!entry) { + pos = begin + BEGIN.length; + continue; + } + // Zeiger vorwaerts bis zur ersten END-Stelle hinter dem Kopf (Positionen wachsen nur). + while (entry.next < entry.starts.length && entry.starts[entry.next] < header.end) entry.next++; + if (entry.next >= entry.starts.length) { + pos = begin + BEGIN.length; + continue; + } + const endStart = entry.starts[entry.next]; + const endEnd = entry.ends[entry.next]; + blocks.push({ + label: header.label, + body: text.slice(header.end, endStart), + text: text.slice(begin, endEnd), + }); + pos = endEnd; + } + return blocks; +} diff --git a/apps/api/src/cert-manager/zip-expand.spec.ts b/apps/api/src/cert-manager/zip-expand.spec.ts index 7c6c636..8d49097 100644 --- a/apps/api/src/cert-manager/zip-expand.spec.ts +++ b/apps/api/src/cert-manager/zip-expand.spec.ts @@ -127,8 +127,8 @@ describe('expandZip: Grenzen und Fehler', () => { AdmZip.prototype.getEntries = function patched(this: AdmZip) { const entries = original.call(this); for (const e of entries) { - const get = e.getData.bind(e); - e.getData = () => { + const get = e.getCompressedData.bind(e); + e.getCompressedData = () => { inflated++; return get(); }; @@ -154,3 +154,71 @@ describe('expandZip: Grenzen und Fehler', () => { expect(r.blobs).toEqual([]); }); }); + +/** Setzt die deklarierte Groesse im Zentralverzeichnis (Offset 24 des Eintrags) auf `size`. */ +function patchDeclaredSize(zip: Buffer, size: number): Buffer { + const copy = Buffer.from(zip); + const sig = Buffer.from([0x50, 0x4b, 0x01, 0x02]); + let at = copy.indexOf(sig); + while (at !== -1) { + copy.writeUInt32LE(size, at + 24); + at = copy.indexOf(sig, at + 4); + } + return copy; +} + +describe('expandZip: ZIP-Bombe mit gefaelschter Groesse (Review CR-01)', () => { + // 200 MiB Nullbytes packen auf rund 200 kB; die Kopfdaten behaupten Groesse 0. + const bomb = patchDeclaredSize(zipOf({ 'bombe.pem': Buffer.alloc(200 * 1024 * 1024) }), 0); + + it('Groesse 0 im Kopf: wird abgewiesen, ohne 200 MiB zu entpacken', () => { + const before = process.memoryUsage().arrayBuffers; + const started = Date.now(); + const r = expandZip(bomb, 'bombe.zip', 0); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'bombe.zip/bombe.pem', reason: 'tooLarge' }]); + // Hartes Deckel-Entpacken: hoechstens die Einzelgrenze (1 MiB) liegt je im Speicher. + expect(process.memoryUsage().arrayBuffers - before).toBeLessThan(50 * 1024 * 1024); + expect(Date.now() - started).toBeLessThan(2000); + }); + + it('Groesse 1 im Kopf (kleiner als echt) ist ebenso wirkungslos', () => { + const r = expandZip(patchDeclaredSize(bomb, 1), 'bombe.zip', 0); + expect(r.blobs).toEqual([]); + expect(r.ignored[0].reason).toBe('tooLarge'); + }); + + it('ehrliche Groesse am Deckel bleibt wie bisher abgelehnt', () => { + const r = expandZip(zipOf({ 'x.pem': Buffer.alloc(2 * 1024 * 1024) }), 'e.zip', 0); + expect(r.blobs).toEqual([]); + expect(r.ignored[0].reason).toBe('tooLarge'); + }); + + it('Eintrag unter der Einzelgrenze mit falscher Kopfgroesse ist suspicious', () => { + const honest = zipOf({ 'k.pem': Buffer.from('abc'.repeat(100)) }); + const r = expandZip(patchDeclaredSize(honest, 0), 'k.zip', 0); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'k.zip/k.pem', reason: 'suspicious' }]); + }); + + it('Summe der echten Groessen ueber der Grenze lehnt das ganze ZIP ab, auch bei Kopfgroesse 0', () => { + const body = Buffer.alloc(300, 65); + const zip = patchDeclaredSize(zipOf({ 'a.pem': body, 'b.pem': body }), 0); + const r = expandZip(zip, 's.zip', 2, { + ...ZIP_LIMITS, + maxEntryBytes: 1000, + maxTotalBytes: 400, + }); + expect(r.blobs).toEqual([]); + expect(r.ignored).toEqual([{ file: 2, path: 's.zip', reason: 'zipTooLarge' }]); + }); + + it('kaputte CRC ergibt brokenZip statt Daten', () => { + const zip = Buffer.from(zipOf({ 'c.pem': 'inhalt' })); + const sig = Buffer.from([0x50, 0x4b, 0x01, 0x02]); + zip.writeUInt32LE(0xdeadbeef, zip.indexOf(sig) + 16); + const r = expandZip(zip, 'c.zip', 0); + expect(r.blobs).toEqual([]); + expect(r.ignored[0].reason).toBe('brokenZip'); + }); +}); diff --git a/apps/api/src/cert-manager/zip-expand.ts b/apps/api/src/cert-manager/zip-expand.ts index f5f4795..e17635d 100644 --- a/apps/api/src/cert-manager/zip-expand.ts +++ b/apps/api/src/cert-manager/zip-expand.ts @@ -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 }); diff --git a/apps/api/src/common/public-url-guard.spec.ts b/apps/api/src/common/public-url-guard.spec.ts index 1d3e515..5e7351e 100644 --- a/apps/api/src/common/public-url-guard.spec.ts +++ b/apps/api/src/common/public-url-guard.spec.ts @@ -115,6 +115,24 @@ describe('isPublicHttpUrl', () => { expect(lookupMock).not.toHaveBeenCalled(); }); + it('IPv6 als Zahl in eckigen Klammern: interne Schreibweisen gesperrt, oeffentliche erlaubt (Review IN-01)', async () => { + for (const blocked of [ + 'http://[::1]/', + 'http://[::ffff:7f00:1]/', + 'http://[::ffff:127.0.0.1]/', + 'http://[fe80::1]/', + 'http://[fc00::1]/', + 'http://[64:ff9b::7f00:1]/', + 'http://[2002:7f00:1::1]/', + 'http://[::7f00:1]/', + ]) { + expect(await isPublicHttpUrl(new URL(blocked)), blocked).toBe(false); + } + expect(await isPublicHttpUrl(new URL('http://[2606:4700:4700::1111]/'))).toBe(true); + expect(await isPublicHttpUrl(new URL('https://[2001:4860:4860::8888]:443/'))).toBe(true); + expect(lookupMock).not.toHaveBeenCalled(); + }); + it('lehnt ab, wenn die Aufloesung scheitert oder nichts liefert', async () => { lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND')); expect(await isPublicHttpUrl(new URL('http://gibt-es-nicht.example.test/'))).toBe(false); diff --git a/apps/api/src/common/public-url-guard.ts b/apps/api/src/common/public-url-guard.ts index aa864c7..211706c 100644 --- a/apps/api/src/common/public-url-guard.ts +++ b/apps/api/src/common/public-url-guard.ts @@ -159,14 +159,16 @@ export async function isPublicHttpUrl(url: URL): Promise { return false; } - const directVersion = isIP(url.hostname); + // URL.hostname behaelt bei IPv6 die eckigen Klammern ("[::1]"); isIP kennt sie nicht (Review IN-01). + const host = url.hostname.replace(/^\[|\]$/g, ''); + const directVersion = isIP(host); if (directVersion !== 0) { - return !isPrivateIpAddress(url.hostname); + return !isPrivateIpAddress(host); } try { - const addresses = await lookup(url.hostname, { all: true }); + const addresses = await lookup(host, { all: true }); if (addresses.length === 0) return false; diff --git a/apps/api/src/http-setup.spec.ts b/apps/api/src/http-setup.spec.ts new file mode 100644 index 0000000..67f6623 --- /dev/null +++ b/apps/api/src/http-setup.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CERT_BUILD_ROUTE } from './cert-manager/cert-json-body'; +import { configureHttp, type HttpApp } from './http-setup'; + +function recordingApp() { + const calls: string[] = []; + const app: HttpApp = { + use: vi.fn((...args: unknown[]) => { + calls.push(typeof args[0] === 'string' ? `use:${args[0]}` : 'use'); + }), + useGlobalPipes: vi.fn(() => { + calls.push('pipes'); + }), + enableCors: vi.fn(() => { + calls.push('cors'); + }), + } as unknown as HttpApp; + return { app, calls }; +} + +describe('configureHttp (Review WR-01)', () => { + it('der JSON-Leser fuer build steht hinter CORS, damit 413 und 400 CORS-Kopfzeilen tragen', () => { + const { app, calls } = recordingApp(); + configureHttp(app, 'http://localhost:3000'); + expect(calls.indexOf(`use:${CERT_BUILD_ROUTE}`)).toBeGreaterThan(calls.indexOf('cors')); + expect(calls.indexOf('cors')).toBeGreaterThanOrEqual(0); + }); + + it('CORS bekommt den Ursprung und Anmeldedaten', () => { + const { app } = recordingApp(); + configureHttp(app, 'https://alpha.example'); + expect(app.enableCors).toHaveBeenCalledWith({ + origin: 'https://alpha.example', + credentials: true, + }); + }); + + it('Anfragelog und Cookies kommen vor allem anderen', () => { + const { app, calls } = recordingApp(); + configureHttp(app, 'x'); + expect(calls.slice(0, 2)).toEqual(['use', 'use']); + }); +}); diff --git a/apps/api/src/http-setup.ts b/apps/api/src/http-setup.ts new file mode 100644 index 0000000..9756281 --- /dev/null +++ b/apps/api/src/http-setup.ts @@ -0,0 +1,47 @@ +import { type INestApplication, ValidationPipe } from '@nestjs/common'; +import cookieParser from 'cookie-parser'; +import { + CERT_BUILD_ROUTE, + certBuildBodyErrors, + certBuildJsonBody, +} from './cert-manager/cert-json-body'; +import { requestLogMiddleware } from './common/request-log'; + +/** Der Teil der Nest-Anwendung, den die HTTP-Einrichtung braucht (so laesst sich die Reihenfolge testen). */ +export type HttpApp = Pick; + +/** + * Gemeinsame HTTP-Einrichtung der API (aus main.ts, damit die Reihenfolge testbar ist). + * + * Reihenfolge der Schichten bei Express: Anfragelog, Cookies, CORS, dann der eigene JSON-Leser + * fuer POST build des Zertifikat-Managers. Der Leser muss HINTER enableCors stehen (Review WR-01): + * seine Fehlerantworten 413 und 400 entstehen, bevor Nests Schichten laufen, und ohne vorherige + * CORS-Schicht blockiert der Browser sie im Entwicklungsaufbau mit getrennten Ursprungsadressen + * (die Oberflaeche sieht dann nur einen Netzwerkfehler statt tooLarge / invalidInput). + * Er steht trotzdem vor Nests globalem JSON-Leser, der erst beim Start (listen) eingebaut wird. + */ +export function configureHttp(app: HttpApp, corsOrigin: string): void { + // Eine Zeile je Anfrage im Docker-Log (Pfad, Status, Dauer, Benutzer) + app.use(requestLogMiddleware); + + // Cookie parser for JWT httpOnly cookies + app.use(cookieParser()); + + // Global validation pipe with whitelist and transform + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ); + + // CORS with credentials for cross-origin cookie support (Pitfall 4) + app.enableCors({ + origin: corsOrigin, + credentials: true, + }); + + // Eigene JSON-Grenze (512 KiB) nur fuer POST build des Zertifikat-Managers, nach CORS und vor + // Nests globalem Leser (quick-261009-ikt D-26, WR-01) + app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors); +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 6d0ff4f..f119c84 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,44 +1,14 @@ -import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; -import cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; -import { - CERT_BUILD_ROUTE, - certBuildBodyErrors, - certBuildJsonBody, -} from './cert-manager/cert-json-body'; -import { requestLogMiddleware } from './common/request-log'; import { formatAppVersionLine } from './health/app-version'; +import { configureHttp } from './http-setup'; async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); - // Eine Zeile je Anfrage im Docker-Log (Pfad, Status, Dauer, Benutzer) - app.use(requestLogMiddleware); - - // Cookie parser for JWT httpOnly cookies - app.use(cookieParser()); - - // Eigene JSON-Grenze (512 KiB) nur fuer POST build des Zertifikat-Managers, vor Nests globalem Leser - // (quick-261009-ikt D-26) - app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors); - - // Global validation pipe with whitelist and transform - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - transform: true, - }), - ); - - // CORS with credentials for cross-origin cookie support (Pitfall 4) - const corsOrigin = configService.get('CORS_ORIGIN', 'http://localhost:3000'); - app.enableCors({ - origin: corsOrigin, - credentials: true, - }); + configureHttp(app, configService.get('CORS_ORIGIN', 'http://localhost:3000')); await app.listen(3001); console.log('Tessera API running on port 3001');