Pentester-Hygiene zu a6b1dac:
1) Konsolidierung: neuer utils/fileCleanup.ts mit deleteFileAbsolute
(absoluter Pfad, z.B. Multer-Temp) + deleteUploadByRelativePath
(in DB gespeicherter /uploads/-Pfad). Ersetzt die 3x kopierten
deleteFileIfExists/cleanupFile in creditNote-, upload- und
customer-Service.
2) Reihenfolge: In deleteCreditNote/updateCreditNote erst die DB-
Operation, DANN die Datei loeschen. Schlaegt der DB-Schritt fehl,
bleibt die Datei erhalten (kein ins-Leere-zeigender Zustand).
Verifiziert: Update -> pdfPath null + alte Datei weg; Delete -> gibt
geloeschte Row zurueck (Audit) + Datei weg. Kein Regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kunden-Feld vatExempt (Kleinunternehmer/USt-befreit §19) + Migration
(ADD COLUMN IF NOT EXISTS). Checkbox im Kundenformular nur fuer
Firmenkunden.
Gutschrift-Vorbelegung vatRelevant wird aus dem Kunden abgeleitet:
Firmenkunde ohne USt-Befreiung -> USt-relevant an (Netto), sonst aus
(wie Privat). Jede Gutschrift speichert ihren eigenen Snapshot, ein
spaeterer Statuswechsel des Kunden aendert bestehende Gutschriften
nicht.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Der Retest deckte auf, dass abgewehrte Cross-Customer-Zugriffe
(canAccess*-403) nur im SecurityEvent-Monitoring-Stream landeten
(ACCESS_DENIED, /api/monitoring/events) – nicht im AuditLog, wo
der Pentester suchte. Der Monitoring-Stream ist zudem löschbar
(DELETE /api/monitoring/events) und nicht hash-verkettet.
emitAccessDenied schreibt jetzt zusätzlich einen tamper-evidenten
AuditLog-Eintrag (action READ, resourceType 'AccessDenied',
Sensitivity HIGH), der ein Clearen des Monitoring-Streams
überlebt und über die Hash-Kette manipulationssicher ist. Gilt
für alle canAccess*-403 (Contract + Customer, inkl. Vollmacht-
fehlt-Fall). Kein Enum-/Schema-Change: READ + distinktiver
resourceType, filterbar via searchAuditLogs.
Korrigiert damit auch meine frühere ungenaue Aussage „landet im
Audit" – vorher war das die falsche Tabelle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MEDIUM: PUT /api/stressfrei-emails/:id und 6 weitere Update-
Endpunkte (platform, tariff, contractCategory, cancellationPeriod,
contractDuration, email-providers) reichten req.body ungefiltert
an Prisma. Gleiche Bug-Klasse wie das gefixte M1-Finding, sieben
Stellen mehr. Nachgewiesen via provisionError-Feld ausserhalb des
TS-Types.
Fix: sieben Whitelists + pickXxxUpdate()-Helper in sanitize.ts,
in den jeweiligen Controllern eingehängt. Reuse der bewährten
pick()-Infrastruktur (Customer/User seit Runde 7).
EmailProvider bewusst OHNE stripHtmlFromStrings, weil Passwörter
und API-Keys legitim Sonderzeichen enthalten dürfen.
Doku: SECURITY-HARDENING.md § Runde 110 + docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INFO-Finding: die interne CRM-Vertragsnummer war im Update-Path
nicht durch validateContractIdentifier abgedeckt. <script>… →
stripHtml → leerer String → Vertragsnummer silent überschrieben
(R87.1-Pattern auf einem sechsten Feld).
Fix: contractNumber zu CONTRACT_IDENTIFIER_FIELDS hinzugefügt.
Bestehende Whitelist (^[A-Za-z0-9_\-/. ]{0,100}$) und Raw-Input-
Validierung greifen automatisch. Legitime generierte Nummern
(GAS-MPVJ4P10FE8) bleiben OK, <script>/Overflow/CRLF/Tags → 400.
Kein Migration- oder Frontend-Change – der Fix nutzt den
bestehenden R86-Mechanismus.
Doku in SECURITY-HARDENING.md § Runde 102 und docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R95.1 MEDIUM: foo\r\nBcc:evil@x.de → Header-Injection-Vektor
R95.3 LOW: <script>...</script>@x.de → silent stripHtml-Mutation
R95.4 LOW: >190 Zeichen → VARCHAR-Overflow → 500 statt 400
Fix: validatePortalUsername() in sanitize.ts mit Whitelist
^[A-Za-z0-9_\-/.@+ ]{0,100}$. Strukturell sind CRLF, Tab, alle
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
Check. Max 100 → ApiError(400) → R95.4. Raw-Input vor stripHtml
geprüft (R87-Pattern). Eingehängt in sanitizeContractBody.
R95.2 (Email-Format-Pflicht) bewusst NICHT übernommen:
portalUsername ist im Manual-Modus nicht zwingend eine Email
(Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder
Pseudonyme als Portal-Login). Doku in SECURITY-HARDENING.md
§ Runde 95.
Frontend: maxLength={100} am Input als UX-Schicht.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R89.1 MEDIUM + R89.2 LOW: sanitizeNotes(…, 500) macht silent
slice(0, 500) statt 400, und stripHtml lief vor dem Length-
Check – `<script>…</script>` reduzierte auf "" → null in DB
→ vorheriger Wert silent überschrieben (R87.1-Pattern auf
Adress-Feldern).
Fix: validateProviderAddress() in sanitize.ts – Raw-Input,
max 500 mit ApiError(400), Blacklist <, >, Tab + alle
Control-Chars außer \n. CRLF → LF VOR dem Length-Check, damit
Editoren mit \r\n-Line-Endings nicht doppelt zählen. Eingehängt
in stripProviderStrings für contactAddress/cancellationAddress.
R89.3/R89.4 (Quotes/\n) bewusst akzeptiert – Pentester selbst
sagt "kein Risiko", sind in Adressen legitim.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R86.1 LOW + R86.2 LOW: >999-Zeichen liefen in DB-Overflow (500
statt 400), Attribut-Injection (`foo" onerror=…` ohne
umschließenden Tag) überlebte stripHtml.
Fix: validateContractIdentifier() (max 100,
^[A-Za-z0-9_\-/. ]{0,100}$) in sanitize.ts, eingehängt in
sanitizeContractBody. Wirft ApiError(400, …). Literales Space
statt \s → kein CRLF/Tab → kein Header-Injection-Vektor in
CSV-/Mail-/PDF-Export. Greift auf alle fünf Identifier-Felder
(Provider + Sales-Platform). ContractForm-Inputs bekommen
maxLength={100} als UX-Schicht.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contract.orderNumberAtSalesPlatform (VARCHAR(191) NULL) mit
Migration 20260619100000_contract_order_number_at_sales_platform
(IF NOT EXISTS). Form-Input, Detail-Zeile mit Copy-Button,
Audit-Mapping, Renewal-Copy und XSS-Strip-Allowlist analog zu
den bestehenden Sales-Platform-Feldern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Viele Vertriebsplattformen vergeben eigene Nummern, die nicht mit
denen des Endanbieters identisch sind. Zwei neue optionale Felder
unter "Anbieter & Tarif".
- Schema: Contract.customerNumberAtSalesPlatform +
contractNumberAtSalesPlatform, Migration mit IF NOT EXISTS.
- ContractForm: zwei neue Inputs direkt unter den entsprechenden
Provider-Feldern.
- ContractDetail: eigene Zeilen mit CopyButton.
- Audit-Log-Mapping + Renewal-Copy + XSS-Strip-Whitelist mitgezogen.
- Bonus: contractNumberAtProvider war im Renewal-Copy und Audit-
Label-Mapping fehlend – mitkorrigiert.
Stage-Bug: User lädt zwei Handy-JPGs als PDF hoch → 415 mit
"PDF enthält JavaScript-Action". Die JPEG-Bytes im jsPDF-Output
enthielten zufällig die Byte-Folge "/JavaScript" → Pattern-Match
auf Binär-Daten statt PDF-Struktur.
Fix: stream..endstream-Blöcke vor dem Scan rauspatchen. Echte
PDF-Actions stehen IMMER außerhalb von Streams (Object-Dictionaries),
Binär-Streams (Bilder/Fonts/Komprimiertes) werden ignoriert.
Smoke-Test: jspdf-Style-PDF mit /JavaScript-Bytes im Stream
durchgewinkt, echte /OpenAction /S /JavaScript blockiert,
clean PDF OK.
24.6 (Portal kann Consent auf PENDING zurücksetzen):
- gdpr.controller updateCustomerConsent prüft jetzt explizit, dass
der Portal-User nur GRANTED oder WITHDRAWN setzen kann. PENDING
ist nur der initiale System-Status; ein Reset darauf hätte die
DSGVO-Auswertung verfälscht.
26.7 (documentPath ohne Validierung):
- Neuer Helper isValidDocumentPath + assertValidDocumentPath in
utils/sanitize: nur /?uploads/<safe>, keine "..", keine
javascript:/data:/vbscript:, kein HTML.
- consent.service.updateConsent ruft den Assert auf – Defense-in-
Depth gegen zukünftige Caller, die documentPath aus User-Input
durchreichen könnten.
- authorization.service.grantAuthorization analog.
- Cleanup-Skript (prisma/cleanup-xss-and-mass-assignment) entfernt
seine lokale Kopie der Path-Validierung und nutzt den shared
Helper – Single Source of Truth.
27.1 (Altdaten in Staging-DB):
- Cleanup-Skript läuft sowieso bei jedem Container-Start. Nina-
Records mit "../../../etc/passwd" werden beim nächsten Restart
genullt (oder verschwinden mit dem VM-Snapshot-Wechsel).
Live-Test isValidDocumentPath: 13/13 OK – legitime Pfade durch,
Traversal/JS-URI/HTML blockiert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assertNoRecentDuplicateDocument warf einen generischen Error → die
Catch-Blöcke in den drei ContractDocument-Schreibpfaden mappten
das auf 500, obwohl es klar eine 400-Class-Situation (Caller-Fehler:
Duplikat-Submit) ist.
Neuer ApiError-Helper in utils/apiError:
- ApiError(statusCode, message) – einfache Subklasse von Error mit
explizitem HTTP-Status.
assertNoRecentDuplicateDocument wirft jetzt ApiError(400, ...).
Catch-Blöcke gehärtet (Service-Pattern: `error instanceof ApiError
? error.statusCode : <default>`):
- contract.controller uploadContractDocument: 400-Default bleibt,
ApiError wird honoriert; bonus: multer-Datei wird bei Reject jetzt
gelöscht (war vorher orphaned bei Lock-Reject).
- cachedEmail.controller saveEmailAsContractDocument: 500-Default,
ApiError → 400.
- cachedEmail.controller saveAttachmentAsContractDocument: dito.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher gingen XSS-Payloads in deliveryDate (saveEmailAsContractDocument,
saveAttachmentAsContractDocument, uploadContractDocument) und
confirmationDate (Cancellation-Confirmation-Upload) mit 200 durch.
Das Datum wurde silent als null behandelt; Impact gering, aber
schlechte API-Hygiene.
Neuer validateOptionalIsoDate-Helper in utils/sanitize:
- ISO-8601-Regex YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS(.fff)?(Z|+HH:MM)?
- null / leerer String / undefined sind OK (Optional-Semantik)
- Sonstige Eingaben werfen 400 mit klarer Meldung
Eingesetzt in:
- contract.controller uploadContractDocument (multer-Datei wird bei
Reject sauber gelöscht)
- cachedEmail.controller saveEmailAsContractDocument +
saveAttachmentAsContractDocument: Validierung früh, BEVOR Dateien
geschrieben werden – kein Datei-Müll bei Reject
- upload.routes handleContractDocumentUpload (cancellationConfirmation*)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Der Fix aus 51.3 deckte nur Contract-PhoneNumber-Felder ab. CRLF in
`Customer.phone`, `Customer.mobile` und (im selben Code-Pfad)
`User.whatsappNumber`, `User.signalNumber` ging weiter durch –
pickCustomerUpdate / pickUserUpdate macht nur stripHtml, das filtert
keine Control-Chars.
- sanitizePhoneField von contract.service nach utils/sanitize gezogen
und EXPORT, damit alle Stellen denselben Allowlist-Check
(/^[0-9+\-/(). ]{0,40}$/) nutzen. Literales Space, NICHT \s.
- customer.controller updateCustomer + createCustomer: phone + mobile
durch sanitizePhoneField → 400 bei CRLF/Control-Chars.
- user.controller updateUser + createUser: whatsappNumber +
signalNumber analog.
- contract.service nutzt jetzt den importierten Helper (Lokale
Kopie entfernt – Single Source of Truth).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node's URL-Parser normalisiert IPv4-mapped IPv6 von Dotted- in
Hex-Form: `::ffff:127.0.0.1` → `::ffff:7f00:1`,
`::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS-Metadata!),
`::ffff:10.0.0.1` → `::ffff:a00:1`.
Die bisherigen Patterns (`::ffff:127\.` etc.) matchten nur die
Dotted-Form. Sobald die URL durch `new URL()` lief, wurde der Host
in Hex-Form herausgereicht und kam an der Blocklist vorbei – live
verifiziert auf test-mail-access mit allen drei Payloads.
Fix in ssrfGuard.ts:
- Neuer extractMappedIPv4-Helper: erkennt Compact-Dotted,
Compact-Hex, Expanded-Dotted, Expanded-Hex – konvertiert auf
Dotted-IPv4.
- Neuer checkIPv4-Helper: läuft die IPv4 durch BLOCKED_PATTERNS
und (optional) PRIVATE_IP_PATTERNS, mit BLOCKED/PRIVATE_HOSTNAMES.
- isBlockedSsrfHost + isPrivateOrBlockedHost rufen den IPv4-Check
bei Mapped-IPv6 zusätzlich auf. Plain IPv4 und Hex-Form werden
damit gleich behandelt.
Verifiziert mit 15-Tests: ::ffff:7f00:1, ::ffff:a9fe:a9fe,
0:0:0:0:0:ffff:7f00:1 etc. werden alle geblockt; legitime IPs
(8.8.8.8, ::ffff:8.8.8.8) bleiben durchlässig.
Nebenbefund (Consent-URL = localhost):
- getPublicUrl in auth.service jetzt EXPORT (vorher private).
- gdpr.controller (sendConsentLink + send-privacy-link) nutzt
jetzt getPublicUrl statt direkt PUBLIC_URL/origin/localhost-
Kette. Damit greift die admin-konfigurierte
AppSetting `portalLoginUrl` auch hier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher lief documentType nur durch stripHtml – ein beliebiger String
("NICHT_ERLAUBT", "DROP TABLE ...", Tippfehler) wurde 1:1 als
ContractDocument.documentType in die DB geschrieben. Das brach
Frontend-Filter, Lieferbestätigung-Auto-Activation und Reports.
Neuer validateContractDocumentType-Helper in utils/sanitize:
- Whitelist ALLOWED_CONTRACT_DOCUMENT_TYPES (8 Werte, gespiegelt aus
Frontend CONTRACT_DOCUMENT_TYPES)
- Case-insensitiver Match, Rückgabe ist immer der kanonische Wert
- Wirft sprechende 400-Fehlermeldung mit Liste der erlaubten Werte
Eingesetzt in allen 3 Schreibpfaden:
- contract.controller.uploadContractDocument (multer-Datei wird bei
Reject sauber gelöscht)
- cachedEmail.controller.saveEmailAsContractDocument
- cachedEmail.controller.saveAttachmentAsContractDocument
Audit-Log + maybeActivateOnDeliveryConfirmation nutzen jetzt den
kanonischen Wert (statt der rohen Eingabe), damit Reports
einheitlich aussehen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57.7 (Consent-Hash ohne TTL):
- Neues Feld Customer.consentHashExpiresAt + Migration
20260601300000_consent_hash_ttl mit IF NOT EXISTS. Bestandsdaten
bekommen NOW()+30d als Default, damit frische Versand-Links nicht
sofort sterben.
- TTL-Konstante CONSENT_HASH_TTL_DAYS = 30 in consent-public.service.
- getCustomerByConsentHash + grantAllConsentsPublic liefern null bzw.
klare Fehlermeldung bei Ablauf; consentHashExpiresAt wird nicht in
der Response durchgereicht (kein Oracle "unbekannt vs. abgelaufen").
- ensureConsentHash erneuert Hash + Frist, sobald der alte abgelaufen
ist – Versand neuer Links bleibt friction-frei.
- consentHashExpiresAt in SENSITIVE_CUSTOMER_FIELDS (sanitize), damit
der Standard-Customer-Endpoint kein Workflow-Info leakt.
57.8 (Zip-Slip / Zip-Bomb):
- Reject zusätzlich: leere Entry-Namen, Backslashes (Cross-OS-
Confusion), Home-Dir-Expansion (`~`), explizite `..`-Segmente
schon im Original-Namen (vor path.resolve).
- Zip-Slip-Check auf path.relative umgestellt – robuster als
startsWith(prefix + sep), insbesondere bei nested Resolution.
- Zip-Bomb-Schutz: 500 MB pro Entry + 5 GB Gesamt-Uncompressed-
Limit; bei Überschreitung Abbruch mit klarer Meldung.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
55.3 HIGH (Contract-Documents ohne Auth abrufbar):
- /uploads/contract-documents/*.pdf war HTTP 200 ohne Token, weil
nginx die Datei direkt ausliefert und Backend nur /api/uploads/*
schützte.
- Defense-in-Depth: app.get('/uploads/*') jetzt ebenfalls mit
authenticate + downloadFile (Ownership-Check) abgesichert.
Falls nginx fehlkonfiguriert sein sollte, fängt das Backend.
55.2 MEDIUM (notes ungestrippt + unlimitiert):
- Neuer sanitizeNotes-Helper: stripHtml + CRLF→LF + Control-Chars
raus + Cap 2000 Zeichen. Eingesetzt für ContractDocument.notes
in allen 3 Schreibpfaden (contract.controller, saveAttachment-
AsContractDocument, saveEmailAsContractDocument).
- documentType zusätzlich stripHtml.
55.4 LOW (Race: 5x Lieferbestätigung → 5 Dokumente):
- Neuer In-Memory-Lock per (contractId, documentType) in
contractStatusScheduler.service. withContractDocumentLock führt
Recent-Duplicate-Check (10s-Window) + Write atomar aus.
- In cachedEmail-Pfaden: fs.writeFileSync ist jetzt INNERHALB des
Locks → kein verwaister Datei-Müll bei Race-Reject.
53.3 (Prisma-Client veraltet bei ungebauten Images):
- docker-entrypoint.sh: `prisma generate` am Container-Start
hinzugefügt. Kostet ~5–10 s, regeneriert den Client gegen das
aktuelle Schema falls jemand ein Stale-Image hochgezogen hat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
51.1 MEDIUM (IPv6-Ranges nicht zuverlässig geblockt):
- URL.hostname liefert IPv6 mit eckigen Klammern ("[::1]") –
safeResolveHost strippt sie jetzt am Eingang, sonst greift
weder net.isIP noch das Regex-Matching.
- PRIVATE_IP_PATTERNS auf Hex-Group-Boundaries gehoben:
/^f[cd][0-9a-f]{2}:/i deckt fc00..fdff zuverlässig ab statt
nur "f[cd]" am String-Anfang.
- Ausgeschriebene IPv6-Formen (0:0:0:0:0:0:0:1, 0:0:0:0:0:ffff:10.x)
als eigene Patterns ergänzt; "[::1]" + "0:0:0:0:0:0:0:1" auch
als BLOCKED_HOSTNAMES.
- fe80: zusätzlich für lange Form (/^fe80:0*:/i).
51.2 LOW (CGNAT + Alibaba Metadata):
- 100.64.0.0/10 (RFC 6598 Carrier-Grade-NAT) → BLOCKED_PATTERNS
- 100.100.100.200 (Alibaba Cloud Metadata) → BLOCKED_HOSTNAMES
51.3 LOW (CRLF in phone-Feldern):
- sanitizePhoneField in contract.service.ts: Allowlist
/^[0-9+\-/(). ]{0,40}$/ – Whitespace bewusst auf literales
Space, NICHT \s, weil \s sonst \r\n\t matched und den
Header-Injection-Schutz aufhebt. Eingesetzt auf phoneNumber
und areaCode in beiden Create-Pfaden und im Update-Pfad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48.1 (XSS in customerEmailLabel):
- Neuer sanitizeCustomerEmailLabel-Helper (stripHtml + trim +
60-Zeichen-Cap)
- Eingesetzt in createProviderConfig + updateProviderConfig
(Write-Pfad) und getProviderPublicSettings (Read-Defensive)
- Damit landet kein <script>/<img onerror>/<svg onload> mehr roh
in der DB, das Längen-Limit ist serverseitig erzwungen, und
Alt-Daten kommen über /public-settings ebenfalls gestrippt raus.
50.1 (SSRF, unvollständige Blockliste bei test-connection):
- safeResolveHost + assertAllowedHost akzeptieren jetzt
{ strict: boolean }. strict=true → isPrivateOrBlockedHost
(sperrt 127/8, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7
unabhängig von SSRF_BLOCK_PRIVATE_IPS).
- test-connection und test-mail-access nutzen strict=true per
Default. Opt-out via env SSRF_ALLOW_INTERNAL_TESTING=true
für On-Prem mit internem Plesk.
- Defense-in-Depth: assertAllowedHost wird jetzt auch VOR der
DNS-Resolution auf den Hostname selbst angewendet, damit
Block-Hostnames (z.B. "metadata.google.internal", "localhost")
nicht via custom-DNS umgangen werden können.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46.1 HIGH (Stored XSS via provider.portalUrl): PUT /api/providers/:id
nahm `javascript:alert(...)` als portalUrl ohne Validierung an, das
Portal rendert es als <a href={portalUrl}> → Klick im Kunden-Browser
löste XSS aus.
Fix: neuer zentraler Helper backend/utils/url.validateHttpUrl
- erlaubt nur http(s)-Schemas (sperrt javascript:, data:, file:,
vbscript:, blob: usw.)
- erfordert absoluten URL mit Host
- per Default keine privaten/Loopback-Hosts (über
isPrivateOrBlockedHost), weil der Wert Endkunden gezeigt wird
- Trailing-Slash wird gestrippt
Eingebaut in:
- provider.service createProvider + updateProvider (HIGH-Fix)
- appSetting.service validateSettingValue für portalLoginUrl
(Refactor der bestehenden ad-hoc Validierung → konsolidiert)
Defense-in-depth Frontend: frontend/utils/url.safeHttpUrl liefert
URLs nur zurück wenn http(s), sonst undefined. Eingesetzt in
ContractDetail bei Portal-Link-Rendering und Auto-Login, damit
Alt-Daten in der DB (vor diesem Fix angelegt) nicht klickbar
bleiben.
INFO-Konsolidierung: damit ist die Schema-/Host-Validierung
einheitlich an einer Stelle. Sanitize-Layer (stripHtml in
sanitize.ts) bleibt für reine Text-Felder zuständig.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
43.6 MEDIUM: ContractHistoryEntry.title + .description waren auf
beiden Pfaden ungestrippt – Admin konnte HTML/Script-Tags
einschreiben, Portal-User las sie roh zurück. Fix: stripHtml()
auf Create + Update (Write-Pfad) und sanitizeEntry() im List +
Get (Read-Pfad), damit Alt-Daten ebenfalls clean rausgehen.
43.5 INFO: stripHtml ersetzt javascript: -> blocked: – sinnvoll
bei URL-Feldern, hässlich in Tarif-/Preis-Namen ("blocked:alert(1)"
als Preis). Neuer stripForDisplay-Wrapper entfernt den Marker
zusätzlich in CONTRACT_DISPLAY_STRING_FIELDS + CUSTOMER_DISPLAY_
STRING_FIELDS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Die drei Preisfelder sind im Schema String? (freitextlich für
Angaben wie "0,28 €/kWh"). sanitizeContract strippte sie auf
dem Read-Pfad nicht – damit lieferten Alt-Daten mit XSS-Payloads
("<script>alert(1)</script>") sie 1:1 an die UI aus.
Defense-in-Depth: Write-Pfad hat sanitizeContractBody, das alle
String-Felder rekursiv stripped. Diese Read-Time-Variante
schützt zusätzlich vor Alt-Daten und einem kompromittierten
Admin-Account.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In der Vertragsübersicht tauchen rohe <script>/<img>-Payloads als
Plaintext auf – React escaped sie zwar (kein XSS), sie sehen aber
hässlich aus. Ursprung: Daten aus pre-Pentest-Zeit, bevor
sanitizeContractBody beim Write existierte.
Fix: sanitizeContract und sanitizeCustomer strippen jetzt zusätzlich
HTML in den definierten Display-Feldern (providerName, tariffName,
customerNumberAtProvider, firstName, lastName, companyName, etc.).
Wirkt auch auf nested previousContract + energyDetails.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runde-35-Befund: 34.5 nur teilweise gefixt – Cloud-Metadata
(169.254.x.x) wurde blockiert, aber 10/8, 172.16/12, 192.168/16,
127/8 und localhost gingen weiter durch, weil isBlockedSsrfHost
diese Ranges nur mit SSRF_BLOCK_PRIVATE_IPS=true geprüft hat. Der
Flag steht aber bewusst auf false für on-prem (Plesk auf 127.0.0.1).
Threat-Modell-Unterschied: portalLoginUrl ist eine URL in
*Endkunden-Mails*. Kunden können 127.0.0.1/192.168.x.x ohnehin nicht
erreichen → kein legitimer Wert. Daher muss der Check hier strikt
sein, unabhängig vom on-prem-Flag (der gilt nur für ausgehende
Server-zu-Server-Verbindungen wie Provider-Test-Connection).
Neuer isPrivateOrBlockedHost() in ssrfGuard.ts: union aus
BLOCKED_PATTERNS (Metadata/Multicast/Reserved) und
PRIVATE_IP_PATTERNS (10/8, 172.16/12, 192.168/16, 127/8, ::1,
fc00::/7) + PRIVATE_HOSTNAMES (localhost, ip6-loopback), egal was
SSRF_BLOCK_PRIVATE_IPS sagt.
portalLoginUrl-Validator nutzt jetzt isPrivateOrBlockedHost +
strippt eckige Klammern aus IPv6-Hostnames (Node URL.hostname
liefert "[::1]" inkl. Brackets).
Live-verifiziert: 22 Test-Cases (9 Private/Loopback, 4 Schemes,
7 legitime). Auch CIDR-Grenzen (172.15 zulässig, 172.16/31
blockiert, 172.32 zulässig).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
30.13 MIME-Extension-XSS (MEDIUM):
GET /api/files/download lieferte hochgeladene Dateien via
res.sendFile() aus. Da multer nur den client-gemeldeten MIME prueft,
konnte eine als application/pdf deklarierte .html-Datei auf Disk
landen – Express liest beim Senden den Content-Type aus der Extension
(text/html), Browser haette gerendert → Stored XSS.
Fix: Content-Disposition: attachment + safe filename. Browser laedt
jetzt herunter statt zu rendern, egal welcher Content-Type. UX-Cost
ist gering (PDF-Preview offnet halt aus dem Download-Ordner).
X-Content-Type-Options: nosniff bleibt zusaetzlich gesetzt.
30.14 SSRF Private-IP-Block opt-in (INFO):
ssrfGuard erlaubte private IPs (127/10/172.16/192.168) bewusst, weil
On-Prem-Setups Plesk/Dovecot/Postfix lokal laufen lassen. Fuer
Cloud-Deployments ist das ein SSRF-Vektor. Neuer Env-Flag
SSRF_BLOCK_PRIVATE_IPS=true erweitert die Block-Liste um alle
privaten Ranges + ::1 + fc00::/7 + IPv4-mapped + localhost/
ip6-localhost. Default off (on-prem-kompatibel).
Live-verifiziert auf dev:
- Download-Header: Content-Disposition: attachment + safe filename
- Default: 127.0.0.1/10.x/192.168.x/localhost durchgelassen,
169.254.169.254 (Cloud-Metadata) weiter geblockt
- SSRF_BLOCK_PRIVATE_IPS=true: alle privaten Ranges geblockt,
8.8.8.8 (legitim) durchgelassen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
28.1 URI-Schema unvollstaendig:
DANGEROUS_URI_SCHEMES erweitert um file:/ftp: – "ftp://evil.com/x.js"
und "file:///etc/passwd" wurden vorher in companyName akzeptiert.
28.2 HTML-Entity-Decoding-Bypass:
stripHtml() lief direkt ueber den Roh-String, "javascript:",
"<script>" und "<script>" umgingen die Regex.
decodeHtmlEntities() dekodiert jetzt numerische (decimal+hex) +
gaengige named entities VOR dem Tag-/URI-Strip.
28.3 Vollmacht-Upload Magic-Byte-Check:
multer pruefte nur client-MIME, HTML/PHP/Shell-Scripts kamen als
application/pdf durch. uploadAuthorizationDocument liest jetzt die
ersten 5 Bytes und verlangt "%PDF-", sonst Loeschen + 400.
28.4 Rate-Limit auf /api/public/consent:
30 Requests pro IP pro 15min. Brute-Force-sicher war der 128-bit-
UUID-Hash schon, aber ohne Limit konnte ein Angreifer das System
mit Audit-Log- und Mail-Spam belasten.
Live-verifiziert auf dev: alle vier Bypaesse blockiert, legitime
Eingaben unangetastet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MEDIUM – Consent-Mass-Assignment:
PUT /api/gdpr/customer/:id/consents/:type nahm source/documentPath/
version ungefiltert aus dem Body. Portal-User konnte
source="ADMIN_OVERRIDE", version="<script>" oder
documentPath="../../etc/passwd" durchschmuggeln.
Fix: nur status aus Body, source server-seitig auf "portal"
hardcoded, documentPath/version bleiben NULL (werden dediziert
vom Authorization-Upload server-seitig gesetzt). Whitelist
ALLOWED_CONSENT_SOURCES für source-Werte. grantAuthorization
(Admin) erzwingt die Whitelist ebenfalls; notes läuft jetzt
durch stripHtml.
LOW – javascript:-URI in companyName:
stripHtml() entfernte HTML-Tags, ließ aber javascript:/data:/
vbscript:-Schemata stehen. companyName="javascript:alert(1)"
hätte in <a href={companyName}> aktiv werden können.
Fix: stripHtml ersetzt jene Schemata mit "blocked:" – legitimer
Text bleibt unangetastet, das Schema wird unschädlich.
LOW – documentPath ohne Validierung:
Bereits durch obigen Consent-Fix erledigt; Cleanup-Pass strippt
zusätzlich vorhandene dreckige Pfade.
cleanup-xss-and-mass-assignment.ts: neue cleanupConsents() läuft
beim Container-Start, normalisiert source per Whitelist auf
"unknown" + stripHtml über version/documentPath.
Live-verifiziert auf dev (alle drei Payloads geblockt + Cleanup
auf dirty DB greift).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pentest Runde 15:
20.3 KRITISCH:
PUT /customers/:id gab portalPasswordHash (bcrypt $2a$12$…) im
Response zurück. updateCustomer reichte das rohe Service-Output
ohne sanitize-Aufruf durch.
20.4 HOCH (gleiche Klasse):
PUT-Response leakte portalPasswordResetToken, portalPasswordMustChange,
consentHash, portalTokenInvalidatedAt.
Fix:
- updateCustomer + createCustomer rufen sanitizeCustomer bzw.
sanitizeCustomerStrict je nach customers:update-Permission.
- updateContract + createContract + createFollowUp + createRenewal
analog mit sanitizeContract / sanitizeContractStrict je nach
isCustomerPortal.
- portalPasswordMustChange + portalTokenInvalidatedAt von
PORTAL_HIDDEN_CUSTOMER_FIELDS zu SENSITIVE_CUSTOMER_FIELDS
hochgezogen → greift auch in normaler sanitizeCustomer
(Admin-Sicht).
Live-verifiziert:
- Admin PUT /customers/3 → 0 Leaks von Hash/Token/Expires/MustChange/
consentHash/TokenInvalidatedAt; portalPasswordEncrypted bleibt
für Admin sichtbar (UI-Workflow, separater Endpoint mit Audit)
- POST /customers → 0 Leaks
- Portal-User GET /customers/3 → 0 Leaks auch bei
portalPasswordEncrypted/notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M2-Reste – XSS-Strings + Mass-Assignment-Settings noch in DB:
Idempotentes Cleanup-Script prisma/cleanup-xss-and-mass-assignment.ts.
Strippt HTML aus Customer/User-String-Feldern, entfernt AppSettings
ohne Whitelist-Eintrag. Wird im entrypoint.sh nach Migrations + Seed
einmalig pro Container-Start ausgeführt.
User-Update + password-Feld:
password aus USER_UPDATABLE_FIELDS raus (CREATE behält es), neuer
dedizierter Endpoint POST /api/users/:id/password mit Audit-Log
"Passwort … durch Admin gesetzt" und Komplexitäts-Check.
JS-Runtime-Fehler-Leak:
ORM_LEAK_PATTERNS um TypeError/ReferenceError/SyntaxError/RangeError +
"Cannot read properties of undefined/null" + "is not a function/
defined" erweitert. Greift im globalen res.json()-Wrapper.
POST /contracts substring-Crash:
Controller validiert type/customerId, sonst 400. generateContractNumber
fängt nullish type ab (Fallback "CON").
Seed-Admin-Passwort:
Default "admin" verletzte 12-Zeichen-Policy. Jetzt 16-char
Zufallspasswort (alle 4 Klassen garantiert via Fisher-Yates) oder per
SEED_ADMIN_PASSWORD-ENV überschreibbar. BCRYPT-Cost 12 (war 10).
Passwort wird einmalig in stdout ausgegeben mit Warnung.
AppSettings-Whitelist: companyName + defaultEmailDomain ergänzt
(kamen aus seed.ts, in 1. Whitelist vergessen).
Live-verifiziert:
- POST /contracts {} → 400 "Vertrags-Typ erforderlich" (vorher
TypeError-Stack)
- PUT /users/6 {password:"HackerPW2026!"} → 200 aber Login mit altem
PW geht weiter
- POST /users/6/password mit "kurz" → 400 mit Komplexitäts-Fehlern
- Cleanup-Script: planted XSS bereinigt, hackerSetting+debugMode
entfernt, idempotenter Re-Lauf
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pentest Runde 11:
C2 KRITISCH – Factory Reset ohne Bestätigung:
Eingeloggter Admin konnte mit leerem oder beliebigem Body die DB
plätten (3× in einer Pentest-Session passiert). Server erzwingt jetzt
confirm:"FACTORY-RESET-BESTAETIGT" als String. Frontend-API sendet
den Wert automatisch mit.
M1 – Settings Mass Assignment:
PUT /api/settings akzeptierte beliebige Keys (superAdminEmail,
debugMode, allowedOrigins). Neue Whitelist ALLOWED_SETTING_KEYS in
appSetting.service.ts; updateSetting + updateSettings prüfen jeden
Key, unbekannte → 400.
M3 – Prisma-Error-Leak:
Statt 30+ Controller einzeln zu fixen, globaler res.json()-Wrapper
unter /api: error/details-Strings werden durch Pattern-Filter
geschickt, der ORM-/Stack-Trace-Muster zu "Operation fehlgeschlagen"
ersetzt. Original bleibt im Server-Log.
M2 – Stored XSS in Customer/User-Strings:
Neuer stripHtml()-Helper. pickCustomerUpdate/Create + pickUserUpdate/
Create rufen ihn auf jeden String-Wert. Defense-in-Depth gegen PDF/
E-Mail-Template-XSS-Vektoren – React-Frontend ist eh auto-escaped.
Live-verifiziert:
- factory-reset {} / {confirm:true} / {confirm:false} → 400, DB ok
- PUT /settings {superAdminEmail,...} → 400 + Keys aufgezählt;
PUT /settings {customerSupportTicketsEnabled:"true"} → 200
- PUT /users/99999 → "Operation fehlgeschlagen" (vorher Prisma-Stack)
- PUT /customers/3 {companyName:"<script>...</script>EvilCorp"} →
gespeichert als "EvilCorp"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pentest Runde 10:
MEDIUM – Stale Token nach Vollmacht-Widerruf:
Selbst ein frischer Portal-Login lieferte JWT mit representedCustomer-
Ids/representedCustomers, obwohl die Vollmacht widerrufen war. Live-
Check beim Datenzugriff fing das ab (403), aber die UI zeigte weiter
„kann vertreten". customerLogin und getCustomerPortalUser (= /me +
Refresh) filtern representingFor jetzt zusätzlich über
getAuthorizedCustomerIds() – nur Beziehungen mit isGranted=true
landen im Token.
MEDIUM – DTO-Leak in embedded Objekten:
GET /customers/:id lieferte contracts[] mit commission/notes/
portalPasswordEncrypted/nextReviewDate; embedded customer in
/contracts/:id zeigte notes. sanitizeCustomer(Strict) ruft jetzt
sanitizeContract(Strict) auf jedes Element von contracts[] auf;
`notes` ist als PORTAL_HIDDEN_CUSTOMER_FIELDS aufgenommen.
LOW – /tasks?customerId=X gibt 200 mit leerem Array statt 403:
Konsistenz-Fix: wenn Portal-User explizit nach customerId filtert,
die er nicht vertreten darf → 403.
Live-verifiziert:
- Customer 1 vertritt 2+3 (Vollmachten widerrufen) → JWT
representedCustomerIds=[], /me dito
- Portal /customers/1.contracts[0]: keine Leaks; Admin sieht weiter
commission/notes; portalPasswordEncrypted generell weg
- Portal /tasks?customerId=2 → 403; /tasks?customerId=1 → 200
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pentest Runde 7 (Anschlussrunde):
MEDIUM – Interne Felder in Portal-Responses:
- sanitizeCustomerStrict strippt zusätzlich portalTokenInvalidatedAt,
portalLastLogin, portalPasswordMustChange, lastBirthdayGreetingYear,
privacyPolicyPath, businessRegistrationPath, commercialRegisterPath.
- Neue sanitizeContract/Strict + sanitizeContracts/Strict: entfernt
portalPasswordEncrypted immer (nur über /password-Endpoint mit Audit
abrufbar), für Portal-User zusätzlich commission/notes/nextReviewDate.
- getContract + getContracts wählen je nach isCustomerPortal die
passende Variante. Mitarbeiter sehen commission/notes weiterhin.
LOW – Integer-Truncation bei IDs:
parseInt('6abc') → 6 lief vorher durch. Neue Heuristik-Middleware
unter /api: jedes Pfad-Segment, das mit Ziffer beginnt aber nicht
aus reinen Ziffern besteht, wird mit 400 abgelehnt. Trifft alle
Sub-Router ohne dass jede Route einzeln angefasst werden muss.
INFO – Rate-Limit: Code-Stand limit=10 für Login, limit=5 für
Password-Reset (lokal verifiziert: 11. failed login = 429). Pentester
sah vermutlich noch älteren Build. Kein Code-Change.
Live-verifiziert:
- /customers/6abc → 400 "Ungültige ID im URL-Pfad"
- /customers/3 → 200, /contracts/1abc/history → 400, normale Pfade OK
- Portal-User /customers/3: keine portalLastLogin/portalPasswordMustChange/
portalTokenInvalidatedAt/etc. mehr in Response
- Portal-User /contracts/15: keine commission/notes/portalPasswordEncrypted/
nextReviewDate
- Admin /contracts/15: commission/notes/nextReviewDate sichtbar,
portalPasswordEncrypted weg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KRITISCH – change-initial-portal-password ohne mustChange-Pflicht-Check:
Jeder Portal-User konnte jederzeit sein Passwort ohne Kenntnis des
alten ersetzen (XSS-/Token-Hijack-Eskalation). Endpoint war NUR für
den OTP-Erst-Login gedacht, prüfte aber das Flag nicht. Fix: Customer
laden, portalPasswordMustChange=true erzwingen, sonst 403.
NIEDRIG – consentHash leakte über GET /customers/🆔
Hash ist Pseudo-Credential für den öffentlichen Consent-Link. Jetzt
in SENSITIVE_CUSTOMER_FIELDS (sanitize.ts) → wird aus jeder customer-
Response gestrippt. Wer ihn legitim braucht, holt ihn über
/gdpr/customer/:id/consent-status.
NIEDRIG – Public consent-grant Response leakte CustomerConsent-Records:
POST /api/public/consent/:hash/grant gab volle Records inkl. ipAddress
und createdBy (Kunden-Name) zurück. Auf { granted: <count> } reduziert
– Frontend liest eh nur success.
Live-verifiziert:
- Change-Initial ohne Flag → 403; mit Flag → 200; danach Flag=false →
erneuter Aufruf 403
- GET /customers/3 → consentHash null, portalPasswordHash null
- /gdpr/customer/3/consent-status → consentHash weiterhin sichtbar
- Public-Grant-Response: {granted: 4}, keine ipAddress/createdBy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
validatePasswordComplexity (12 Zeichen, Groß/Klein/Zahl/Sonderzeichen)
zentral in passwordGenerator.ts; jetzt erzwungen in setPortalPassword,
confirmPasswordReset, register, createUser, updateUser.
Neue Endpoints:
- POST /customers/:id/portal/password/generate → 16-Zeichen Zufallspasswort
- POST /customers/:id/portal/send-credentials → Versand per Mail
(nur wenn portalEnabled aktiv)
Frontend (CustomerDetail): Generate-Button vor Setzen, Send-Credentials
nach gesetztem Passwort, Live-Komplexitäts-Hint (✓/○) während Eingabe,
alert() durch Toast-Notifications ersetzt.
Live-verifiziert: schwaches Passwort → 400 mit Detail-Fehler, komplexes
Passwort → 200, Generator liefert 16-Zeichen-Passwort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`pickUserUpdate`-Whitelist enthielt `hasGdprAccess` und `hasDeveloperAccess`
nicht – sie wurden vom Mass-Assignment-Schutz aus dem Request entfernt,
bevor sie den Service erreichen konnten. Damit lief `setUserGdprAccess` /
`setUserDeveloperAccess` nie und die zwei versteckten Rollen blieben
unzuweisbar (UI-Checkbox hatte keine Wirkung).
Fix: Beide Felder zur Whitelist hinzugefügt – sie sind keine User-Spalten,
der Service mappt sie auf die DSGVO-/Developer-Rollen.
Bonus: Audit-Log-Diff vergleicht jetzt den Pre-State korrekt (User-Rollen
in `before` mitgeladen + Field-Labels), sonst hätte der jetzt durchkommende
Flag immer einen False-Positive-Change "- → Ja" produziert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loose Ends aus Runde 5/7 abgearbeitet.
🛡 DNS-Rebinding-Schutz in SSRF-Guard
- safeResolveHost() löst Hostname zu IPv4+IPv6 auf, prüft jede IP
gegen die Block-Liste, gibt {ip, servername} zurück.
- Caller (test-connection, test-mail-access) übergibt host=ip plus
servername=hostname an die Mail-Services. Damit kann ein zweiter
DNS-Lookup zur Connection-Zeit nicht plötzlich auf interne IPs
umlenken (rebound-Attack).
- ImapCredentials/SmtpCredentials um optionales servername-Feld
erweitert; Services nutzen es als TLS-SNI / Cert-Validation-Hint.
🔒 Per-File-Ownership-Check (DSGVO-Härtung)
- express.static('/api/uploads') ersetzt durch GET /api/files/download
mit Pfad→Resource→Owner-Mapping in fileDownload.service.ts.
- 12 subDir-Mappings (bank-cards, documents, contract-documents,
invoices, cancellation-*, authorizations, business-/commercial-/
privacy-, pdf-templates).
- canAccessCustomer / canAccessContract / Permission-Check je nach
Owner-Typ. Portal-User sieht jetzt nur eigene Dateien, selbst wenn
er fremde Filenames kennt.
- Backwards-Compat: /api/uploads/* bleibt als Shim erhalten, ruft
intern denselben Owner-Check.
- Frontend fileUrl() zeigt auf /api/files/download?path=...&token=...
Live-verifiziert:
- Eigene Datei: 200, random Pfad: 404, ../etc/passwd: 400, kein
Token: 401, Backwards-Compat-Shim: 200.
- DNS-Rebinding: nip.io-Hostname mit interner Target-IP wird via
DNS-Lookup geblockt; gmail.com (legitim) geht durch.
Bewusst nicht gemacht:
- Signierte URLs mit kurzlebigen Download-Tokens – v1.2-Item, da
invasiv für <a href>-Flows ohne JS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🛡 SSRF-Schutz in test-connection / test-mail-access
- Admin-User konnte über apiUrl bzw. SMTP/IMAP-Server-Felder
Connections zu Cloud-Metadata-Endpoints (169.254.169.254,
metadata.google.internal etc.) auslösen. Internal-Port-Scan
über Timing-Differenzen war messbar.
- Fix: neuer utils/ssrfGuard.ts blockiert pre-flight 169.254.0.0/16,
0.0.0.0/8, Multicast/Reserved-Ranges, AWS-IPv6-Metadata,
IPv6-Link-Local und Cloud-Metadata-Hostnames.
Loopback (127.0.0.0/8) bleibt erlaubt – legitime Plesk/Postfix-
Setups sollen weiter funktionieren.
🔒 Logout-Endpoint POST /api/auth/logout
- Setzt tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt.
Auth-Middleware prüft das Feld bereits und lehnt Tokens mit
iat davor ab. Ohne diesen Endpoint blieb ein "abgemeldeter"
JWT bis Expiry (7d) gültig.
Live-verifiziert:
- 169.254.169.254 / metadata.google.internal / 0.0.0.0 → 400
- 127.0.0.1 (Plesk-Fall) weiter erlaubt
- /me vor Logout 200, nach Logout 401 "Sitzung ungültig"
Geprüft + sauber (Runde 7, kein Bug):
- Public Consent (122-bit Random-UUID nicht brute-force-bar)
- Magic-Bytes-Bypass beim Upload
- PDF manualValues Injection (keine HTML-Render-Surface)
- Query-Filter-Override (?customerId=X) – vom Portal-Filter ignoriert
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nach der ersten Runde habe ich parallel 3 Audit-Agents auf die Codebase
angesetzt. Die fanden noch eine Menge: Zip-Slip, Mass Assignment inkl.
Privilege Escalation, 13 weitere IDOR-Stellen, 2x Path-Traversal.
Alles gefixt. Details + Angriffsvektoren in docs/SECURITY-REVIEW.md.
🔴 KRITISCH gefixt:
1. Zip-Slip im Backup-Upload: extractAllTo() entpackte bösartige ZIPs ohne
Pfad-Validierung. Ein Angreifer mit Admin-Zugang hätte mit einem ZIP
mit Entries wie ../../etc/crontab das ganze Filesystem überschreiben
können. Jetzt wird jeder ZIP-Entry einzeln validiert (path.resolve,
starts-with-Check). Absolute Pfade + Null-Bytes werden abgelehnt.
2. Mass Assignment bei Customer/User Controllers:
- updateCustomer/createCustomer: req.body ging komplett an Prisma.
Angreifer konnte portalPasswordHash, portalPasswordResetToken,
consentHash, customerNumber direkt setzen.
- updateUser/createUser: roleIds und isActive waren übernehmbar.
**Privilege Escalation**: normaler Mitarbeiter konnte sich Admin-Rechte
durch PUT /users/:id mit {"roleIds":[1]} geben, oder andere User
deaktivieren.
Fix: Neue Whitelist-Helper pickCustomerCreate/Update, pickUserCreate/Update
in utils/sanitize.ts. Nur erlaubte Felder werden durchgelassen.
3. IDOR bei 13 weiteren Endpoints (neben denen aus Runde 1):
- GET /meters/:meterId/readings
- GET /emails/:emailId/attachments/:filename
- GET /emails/:emailId/attachments (Liste)
- GET /customers/:customerId/emails
- GET /contracts/:contractId/emails
- GET /emails/:id (einzelne Email)
- GET /stressfrei-emails/:id (leakte emailPasswordEncrypted)
- weitere…
Fix: accessControl.ts ausgebaut um canAccessAddress, canAccessBankCard,
canAccessIdentityDocument, canAccessMeter, canAccessStressfreiEmail,
canAccessCachedEmail. In allen betroffenen Endpoints angewendet.
🟡 WICHTIG gefixt:
4. Path-Traversal bei Backup-Name (GET /settings/backup/:name/*): req.params.name
wurde ohne Filter in path.join. Neuer isValidBackupName() erlaubt nur
[A-Za-z0-9_-]+ ohne "..".
5. Path-Traversal bei GDPR-Proof-Download: proofDocument-Pfad aus DB wurde
ohne Validation gejoined. Jetzt path.resolve + starts-with-uploads-Check.
Neue/erweiterte Files:
- backend/src/utils/accessControl.ts - 6 neue can-Access-Helper
- backend/src/utils/sanitize.ts - 4 neue Whitelist-pick-Helper
- docs/SECURITY-REVIEW.md - Runde 2 dokumentiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>