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>
`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>
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>