1. AddressModal: Straße-Feld ließ sich nicht editieren. setFormData
wurde im Render-Body aufgerufen, wenn formData.street !==
address.street → Reset bei jedem Tastendruck. In useEffect mit
[address?.id]-Dependency umgezogen.
2. Multer-Limit von 10 MB auf 25 MB in upload.routes.ts,
gdpr.routes.ts, contract.routes.ts. Zwei Handy-Fotos zu PDF
kratzten am alten Limit. FileUpload-Hinweistext angepasst.
contract.routes.ts Vertragsdokumente-Upload hatte bisher nur den
PDF-Inhalts-Scan aus 68.1. JPG/PNG-Uploads waren ungeprüft, ohne
canonical Rename – Pentester selbst attestiert "ohne Exploit-Pfad"
(Download-Layer fängt's), aber inkonsistent zu allen anderen
Upload-Pfaden.
- Refactor: detectType + validateUploadedFile aus upload.routes.ts
in neue Middleware uploadFileTypeValidator.ts ausgelagert (Single
Source of Truth, ~90 Zeilen Duplikation entfällt).
- contract.routes.ts: validateUploadedFile ersetzt
scanUploadedPdfIfPresent → Magic-Byte + canonical Rename + PDF-Scan
in einer Pipeline.
- pdfUploadSafety.ts: scanUploadedPdfIfPresent entfernt (tot).
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>
56.1 HIGH (IDOR auf Upload-Endpoints):
- /upload/bank-cards/:id (POST/DELETE): canAccessBankCard +
Existenz-Check, multer-Datei wird bei Reject sauber aufgeräumt.
- /upload/documents/:id (POST/DELETE): canAccessIdentityDocument
+ Existenz-Check + Cleanup.
- /upload/customers/:id/{business-registration,commercial-register,
privacy-policy} (POST/DELETE): canAccessCustomer + Cleanup.
- /upload/invoices/:id (POST/DELETE): canAccessContract über
Invoice→Contract-Resolve + Cleanup.
56.2 HIGH (IDOR + Consent-Eskalation bei privacy-policy):
- Vor dem upsert auf alle 4 CustomerConsent-Einträge (=GRANTED)
läuft jetzt canAccessCustomer. Portal-Vertreter ohne Vollmacht
oder Mitarbeiter mit anderer Customer-Beschränkung kommen
damit nicht mehr durch.
56.3 LATENT (updateContract / deleteContract):
- Defense-in-Depth: canAccessContract jetzt explizit im Controller,
nicht nur über die Route-Permission.
56.4 MEDIUM (invoiceType ungeprüft in addInvoiceByContract):
- Neuer assertValidInvoiceType-Helper mit Whitelist
['INTERIM','FINAL','NOT_AVAILABLE'] in addInvoice,
updateInvoice und addInvoiceByContract. updateInvoice nur
bei explizit gesetztem Wert; addInvoiceByContract zusätzlich
die fehlende Required-Field-Validierung ergänzt.
56.5 LOW (GDPR-Löschanfragen ohne Ownership-Check):
- POST /api/gdpr/deletions liest customerId jetzt aus dem Body
(Route hat kein :id-Segment), validiert auf positive Zahl und
ruft canAccessCustomer auf, bevor die Löschanfrage erstellt wird.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upload-Endpoints (/api/upload/...) hatten denselben Mismatch-Vektor
wie schon die Vollmacht-Route (Pentest 28.3): multer prüft nur den
client-gemeldeten MIME-Type, eine `.php`-Datei mit
Content-Type: image/gif rutschte durch und landete als
`<unique>.gif.php` (Doppel-Endung) auf Disk – kein RCE in unserem
Setup, aber dreckige Datei + Inkonsistenz zwischen geliefertem MIME
und tatsächlichem Inhalt.
Fix: neue validateUploadedFile-Middleware nach upload.single(...) –
- liest die ersten 12 Bytes der gerade geschriebenen Datei
- erkennt PDF/PNG/JPEG/GIF/WebP per Magic-Bytes
- bei Mismatch: Datei löschen + 415 "Datei-Inhalt entspricht keinem
zulässigen Typ"
- benennt die Datei auf eine KANONISCHE Endung (.pdf/.jpg/.png/.gif/
.webp) um, abgeleitet aus dem erkannten Typ (NICHT aus
file.originalname). Damit verschwindet `evil.gif.php` zu
`<unique>.gif` (39.4).
- setzt req.file.mimetype auf den erkannten Type, sodass Controller
konsistente Werte sehen.
Eingehängt in allen 10 upload.single('document')-Routes
(bank-cards, documents, business-registrations, commercial-register,
contract-docs etc.).
Live-verifiziert:
- PHP-Datei als image/gif → 415 + Datei gelöscht
- HTML-Datei als application/pdf → 415 + Datei gelöscht
- WebP-Inhalt mit MIME image/png → 200, gespeichert als .webp
- echtes WebP/JPG → 200 mit kanonischer Endung
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pentest 2026-05-30 INFO: Upload-Endpoints lieferten 500 statt
sauberem 4xx, wenn der fileFilter den MIME-Type ablehnte
(z.B. WebP/GIF, die gar nicht in der Allowlist standen) oder
LIMIT_FILE_SIZE getroffen wurde.
Ursache: fileFilter rief cb(new Error(...)) – multer wirft das
weiter, und ohne dedizierten Error-Handler endete es als 500
"Interner Serverfehler" mit Stack-Trace im Log.
Fix:
- WebP + GIF in die Allowlist von upload.routes.ts (Bug-Pen-
test-Erwartung des Reporters).
- Globaler Express-Error-Handler in index.ts unterscheidet jetzt:
* MulterError code=LIMIT_FILE_SIZE → 413 "Datei ist zu groß"
* andere MulterError → 400 "Upload-Fehler: ..."
* Error mit "...erlaubt"-Message → 415 mit Original-Message
* sonst → bisheriger 4xx/500-Pfad
Live-verifiziert:
WebP/GIF/JPG → 200
SVG / text/plain → 415 + klare Message
11 MB PDF → 413 "Datei ist zu groß"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live-Pentest gegen Dev-Server + 3 parallele Audit-Agents.
🚨 CRITICAL: /api/uploads/* war ohne Auth erreichbar
- express.static('/api/uploads', ...) → jeder konnte mit ratbarer URL
sensible PDFs (Kündigungsbestätigungen, Ausweise, Bankkarten,
Vollmachten) ziehen. Live-verifiziert: 23-KB-PDF eines echten Kunden
ohne Login geladen.
- Fix: authenticate-Middleware vor static-Handler (req.query.token
unterstützung war schon da, jetzt aktiv genutzt).
- Frontend: utils/fileUrl.ts hängt JWT als ?token=... an. 24 direkte
/api${...Path}-URLs in 5 Dateien per Skript migriert (CustomerDetail,
ContractDetail, InvoicesSection, PdfTemplates, GDPRDashboard).
🚨 HIGH: Login-Timing User-Enumeration
- bcrypt.compare wurde nur bei existierenden Usern ausgeführt → 110ms
vs 10ms Differenz, Email-Enumeration trivial messbar.
- Fix: Dummy-bcrypt-compare bei invalid user (Cost 12). Plus Lazy-
Rehash bei erfolgreichem Login: alte Cost-10-Hashes (z.B. admin aus
Installation) werden auf BCRYPT_COST upgraded, damit Dummy- und
Echt-Hash-Cost zusammenpassen.
- Live-verifiziert nach Admin-Rehash: 422ms (invalid) vs 423ms (valid)
– Side-Channel dicht.
🚨 HIGH: XSS via Privacy-Policy/Imprint-HTML
- 4 Frontend-Seiten renderten Backend-HTML ohne DOMPurify
(PortalPrivacy, ConsentPage, PortalWebsitePrivacy, PortalImprint).
Admin-eingegebene <script>-Tags wären bei jedem Portal-Kunden-
Besuch ausgeführt worden – auch auf der öffentlichen Consent-Seite.
- Fix: DOMPurify.sanitize mit strikter FORBID_TAGS/ATTR Config.
🛡 HIGH: IDOR-Härtung an Upload-/Document-Endpoints
- canAccessContract jetzt in: uploadContractDocument,
deleteContractDocument, handleContractDocumentUpload (Kündigungs-
Letter+Confirmation), handleContractDocumentDelete,
saveAttachmentAsContractDocument.
- Defense-in-Depth: aktuell durch requirePermission abgesichert,
schützt auch gegen künftige Staff-Scoping-Rollen.
Offen für v1.1:
- Per-File-Ownership-Check für /api/uploads (Kontroll-Lookup
welche Ressource zur Datei gehört)
- TipTap-Link-Tool javascript:-Protokoll blockieren
- Prisma-Error-Messages in Admin-Endpoints generisch sanitisieren
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beim automatischen Status-Wechsel wird jetzt auch das passende Datum gesetzt,
damit Status und Datumsfeld konsistent sind (Cockpit-Warnung "Datum fehlt"
verschwindet sofort nach Upload).
Backend:
- Upload-Handler für Kündigungsbestätigung(s-Optionen) nimmt optional
`confirmationDate` aus multipart an, speichert als
cancellationConfirmationDate / cancellationConfirmationOptionsDate.
Fallback: heute (nur falls Feld noch leer war).
- maybeActivateOnDeliveryConfirmation nimmt optional deliveryDate, setzt
Contract.startDate falls leer. Fallback: heute.
Frontend:
- ContractDetail: neues kleines Modal beim Kündigungsbestätigungs-Upload
fragt das Bestätigungs-Datum ab (Default: heute oder bereits gesetzter
Wert). Der bestehende inline-Datums-Editor bleibt für spätere Korrekturen.
- ContractDocumentsSection: Datums-Input erscheint conditional im
Upload-Bereich, sobald Typ "Lieferbestätigung" gewählt ist.
- SaveAttachmentModal (E-Mail-Anhang → Vertragsdokument): gleicher
Datums-Input conditional für "Lieferbestätigung".
- API-Methoden uploadCancellationConfirmation / uploadDocument /
saveAttachmentAsContractDocument nehmen optional Datum entgegen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Neuer Scheduler (02:00 + Catch-up 60s nach Start): alle ACTIVE-Verträge mit
endDate < heute werden auf EXPIRED umgestellt. Audit-Log pro Vertrag.
- Upload cancellationConfirmationPath: Vertrag wechselt von ACTIVE → CANCELLED
(mit Audit-Log). "Options"-Upload triggert bewusst nicht, da für
Vertragsänderungen gedacht, nicht für echte Kündigungen.
- Keine neuen Statuswerte. "Kündigung gesendet vs. bestätigt" bleibt über die
getrennten Felder cancellationSentDate / cancellationConfirmationDate lesbar,
Status bleibt bis zur Bestätigung auf ACTIVE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>