fix(cert-manager): ZIP-Bombe, PEM-Scanner und Umlaut-Passwörter in PFX behoben
- ZIP: eigenes Entpacken mit hartem Deckel (maxOutputLength), Größe, Verhältnis und CRC am echten Ergebnis; eine gefälschte Kopfgröße 0 umgeht keine Grenze mehr (CR-01) - PEM: linearer Scanner statt Regex mit quadratischer Laufzeit, gemeinsam für Erkennung und Abruf (CR-02) - PKCS#12: PBES2/AES leitet das Passwort aus UTF-8 ab, Lesen und Schreiben, mit OpenSSL-Gegenprobe für „pässwörd“ und „pw€“ (CR-03) - Grenzen je Anfrage: höchstens 200 Zertifikate, 50 Schlüssel, 50 Anfragen (413 tooManyItems); Ableitungsrunden höchstens 1 Million je Verfahren und 6 Millionen je Anfrage (protectionTooExpensive) (WR-04, WR-05) - Upload: Gesamtgrenze beim Empfang, multer-Grenzen für Dateien, Felder, Teile (WR-06) - build-Leser hinter CORS, damit 413/400 CORS-Kopfzeilen tragen (WR-01) - Selbstsignierte Wurzeln ohne basicConstraints (Version 1, keyCertSign) sind Wurzeln, Serverzertifikate bleiben es (WR-07) - Adressschutz: IPv6-Adressen in Klammern werden beurteilt; „CA Issuers“-Filter für Anzeige und Abruf gemeinsam, eigener Code aiaNotAllowed (IN-01, IN-02) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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-<umlaut|euro>-<modern|compat>.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 )
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIIFNjBgBgkqhkiG9w0BBQ0wUzAyBgkqhkiG9w0BBQwwJQQQdA7vJ5D+LYfl6Cs7
|
||||
wsiWpwIDHoSAMAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBB91yzfIbFksyJS
|
||||
3lqbRjRMBIIE0AOZxJA7uht1LJ2W+66QYzhd/+5M4PepjN0vqlYWilx7mdl6t5tv
|
||||
aG5HvF6dxY4PpXvclxAWkgRhNpVwL9fSneFejdEfp/UFHSV/+60LfdY9JCxCB39Z
|
||||
17lyfWjBEdAo3riorh/Jw+X6oXaayH6KfMRHTKbBV7CVry+0sT9ib84A9POZDZDW
|
||||
KfXZA3L+dGpx7QkQo3jKhxVy5rgvba00vgUBsvV1yEMw1TrApOTH6i0LqY/OenXH
|
||||
Hk8tYzvtth7s4D603Rnw68oUa4aCTaFK8xy9CEnoIl1zAZKOq1I/ahBMDDUd+lza
|
||||
YYig14Gk+zat4hfFKi92waFOjhfPRrX874gNiMqcNOu7YDTyoc9TDl7anI3L8JkE
|
||||
y5amj9EzLraurIo231YLfKHJNZdggL4fln1FJrN1VvYQx8sgQX3I6hfS4L5U12Fr
|
||||
aaW8rqUS2Xaz0liybRbpo0De0XFAZmkkgS1OdwQxQ8hvSAReIq0dggibFcU3KgOT
|
||||
PdWwSuLaUKUjj4XtK05/s8Slak9NfBdggi2wLzDf2tPP1PHBysGTKGHryyiY5cJI
|
||||
NKaI35oHHhtjILRStl/O6GbIek4U0orTjlIILeONmr4Lixw/46YQTpd2D4LJggDn
|
||||
OZwaa4aEu0gfvDg5wqMt0N0Gx/9wzneoz5pJENXuXPwzvjAJtllkpWDfoNd+Xq3I
|
||||
oQYlAbaYHPSsqT4mQ9xfSNNtgCMjYtdJcXiI9x6Yc/CaEFh/S3TYbl9evArdpqjj
|
||||
rZa/wyoNzgTPwwhPBij7xrT/5pw5yaP3y/czKIbxep2n6fvOErStysmJSemt2ev3
|
||||
FV2K0I/LJx9qarVZasf+2EVTvoQyCAO2O94R9iBtt3fP7KFR6nxubPqnWYvRCumT
|
||||
lLvtSgw0Zo7edA6wPCS2XbuUqjKzymAAzWTh5z+xexO7q9dPVUVvAERrSq6FMWi7
|
||||
IAKxx3x7hrF9+/HDlOCnqP/cMnk6U+QvWKuI9nZAuZsPYdwJ2tFsACVPq0EPcR7w
|
||||
xugNeb88JLi7tdcSHmZHG9GEF0U5ImmNjcxg2qqODlteovdFPBw0ru/PD8cSZF6F
|
||||
enbPsTrt1rPVMCqi+5NAe1lFGcYGuSLREe+BHzTtbvsdect6mWCg0hlcpA6FioZn
|
||||
TkwkRCruXuj8zzyEp/o3My5/9w7vjCIKuRjcasxisfCZOhTx12Pjv3dTyLP35C0B
|
||||
p00Q4EVKjrygw5V7cNf+K+q7UQ/1X3pY3+cLAgCbSqKTBE2M5PesePdO8m7Hix7W
|
||||
6cJbd+O66gz/GU0s8fIeksAjskWolu/vxIP9dhMksldckjFiQqU4inuaG9QicA3g
|
||||
9Rm/0TDA3oMOFUpA7S0fT9XyWXeP2qa8warE0cKhWv9xrMS+xNMjNCntzsU5lvzN
|
||||
bPD/Pr4tCwueyQOIawV7uKw1UJOMN691FCyjHWOekecFXeLexHT18ROwHcrUhCb2
|
||||
csggt5aB+Z3mDWd55Ljle7ZzLiD1F/ndTkc8IxNU+cK5idYisCtq66LI5Fz8LPOy
|
||||
EJZdSb7rX8RO8+f6TRVmPu3YAq6KKY23EAJdy7ip/6CVJBuBOn3xaYjqIzv1o83g
|
||||
Iu6K4aXcJOSkJvTzBGwcuG6B3pHFMEaOEBAqOIXclqG03K895Kuykk13
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
Binary file not shown.
Binary file not shown.
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> } | 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<string, unknown> }).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<string, number> = {
|
||||
aiaNotIssuer: 4,
|
||||
aiaTooLarge: 3,
|
||||
aiaUnreachable: 2,
|
||||
aiaNotAllowed: 1,
|
||||
aiaInternal: 1,
|
||||
};
|
||||
|
||||
const FAILURE_STATUS: Record<string, number> = {
|
||||
aiaNotIssuer: 422,
|
||||
aiaInternal: 422,
|
||||
aiaNotAllowed: 422,
|
||||
aiaTooLarge: 502,
|
||||
aiaUnreachable: 502,
|
||||
};
|
||||
@@ -235,13 +220,15 @@ const FAILURE_STATUS: Record<string, number> = {
|
||||
const FAILURE_TEXT: Record<string, string> = {
|
||||
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<AttemptResult> => {
|
||||
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<ReturnType<typeof undiciFetch>>;
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
// 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
const key = openWithPasswords((passphrase) => createPrivateKey({ key: text, passphrase }), ctx);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(budget: RequestBudget, fn: () => T, onExpensive?: () => void): T {
|
||||
const pkcs5 = forge.pkcs5 as unknown as Record<string, unknown>;
|
||||
const pbe = (forge.pki as unknown as { pbe: Record<string, unknown> }).pbe;
|
||||
const p12 = forge.pkcs12 as unknown as Record<string, unknown>;
|
||||
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,7 +265,8 @@ 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(
|
||||
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,
|
||||
@@ -186,6 +275,7 @@ export function writePkcs12(options: WritePkcs12Options): Buffer {
|
||||
friendlyName: bmpString(options.friendlyName),
|
||||
generateLocalKeyId: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
return toBuffer(p12);
|
||||
} finally {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<typeof createTotalLimitedStorage>,
|
||||
req: object,
|
||||
chunks: Buffer[],
|
||||
): Promise<Result> {
|
||||
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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -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<object, number>();
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 <Etikett>-----`
|
||||
* 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<string, { starts: number[]; ends: number[]; next: number }>();
|
||||
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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -159,14 +159,16 @@ export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<INestApplication, 'use' | 'useGlobalPipes' | 'enableCors'>;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
+2
-32
@@ -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<string>('CORS_ORIGIN', 'http://localhost:3000');
|
||||
app.enableCors({
|
||||
origin: corsOrigin,
|
||||
credentials: true,
|
||||
});
|
||||
configureHttp(app, configService.get<string>('CORS_ORIGIN', 'http://localhost:3000'));
|
||||
|
||||
await app.listen(3001);
|
||||
console.log('Tessera API running on port 3001');
|
||||
|
||||
Reference in New Issue
Block a user