Files
opencrm/docs/todo.md
T
duffyduckandClaude Opus 4.8 6f95005530 Gutschrift: Sachwert ohne Betrag = keine Rechnung (kein ZUGFeRD)
Eine Sachwert-Gutschrift darf betragslos sein (Betrag leer/0): dann
findet keine Rechnungsstellung statt - der Kunde hat den Gegenstand
einfach als Subvention erhalten.

- Backend: leeres amount -> 0; Sachwert erlaubt 0, Geld verlangt > 0
  (400 sonst). vatRelevant bei 0 erzwungen-false.
- PDF: betragsloser Sachwert -> Titel 'Sachwert-Uebergabe', kein
  Betrags-/USt-Block (Hinweis keine Rechnungsstellung), KEIN ZUGFeRD-
  Embedding. Mit Betrag -> unveraendert ZUGFeRD.
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet ohne
  Betrag; Liste zeigt 'Sachwert ohne Betrag (keine Rechnung)'.

Verifiziert: Sachwert 0 -> kein factur-x.xml; Geld 0 -> 400;
Sachwert 200 -> ZUGFeRD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 13:22:13 +02:00

161 KiB
Raw Blame History

📋 OpenCRM – Todo-Liste


🔜 Offen

Manuelle Tests (vor Release durchklicken)

Checklisten fĂŒr Security + Email-Log-System stehen in TESTING.md. Einmal komplett durchlaufen vor v1.0.0-Release.

🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless

Vision: OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ĂŒber ein zentrales Admin-Portal.

Architektur-Entscheidung: Weg C (Instance-per-Customer)

  • Pro Kunde eine eigene Docker-Instanz mit eigener DB
  • Keine tenantId im CRM-Code → keine Security-Risiken durch vergessene Filter
  • Komplette Datenisolation (DSGVO-freundlich)
  • Updates können gestaffelt ausgerollt werden (erst 10% testen)
  • Bei KĂŒndigung: Docker-Image + DB-Export als "Mitnehm-Paket"

Bewusst NICHT dabei: eigener Mailserver. Stattdessen Plesk-Integration (die wir schon haben) – Kunde bekommt Mail-Zugang ĂŒber unseren Plesk bei Bedarf.


Admin-Portal (separate App, neben den CRM-Instanzen):

  • Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled)
  • "Neuen Kunden anlegen" → Provisioning-Script
    • DB anlegen (Master-DB kennt die Mapping)
    • Docker-Container starten
    • Subdomain konfigurieren (kundenname.deincrm.de via Caddy/Traefik)
    • Initial-Admin-Account erstellen + Einladungs-Email senden
    • Optional: Factory-Defaults fĂŒr Stammdaten einspielen
  • GoCardless-Integration (Webhook + Dashboard)
  • Instanz-Management: Pause/Resume bei Zahlungsproblemen
  • Logs & Metriken pro Instanz (optional)
  • Support-Bereich (Tickets? oder einfach E-Mail)

Abrechnung mit GoCardless (gocardless.com):

  • Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (ĂŒber GoCardless Embedded/Success)
  • 30 Tage kostenlose Testphase ohne Zahlungsmittel
  • Nach Trial: Mandats-Erfassung → regelmĂ€ĂŸige Abbuchung
  • Mehrere PlĂ€ne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features
  • Webhook-Endpoint im Admin-Portal:
    • payment_confirmed → Instanz aktiv lassen
    • payment_failed → Banner im CRM, nach X Tagen pausieren
    • mandate_cancelled → KĂŒndigungs-Flow
  • Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber echte Rechnungen (mit USt-ID, Rechnungsnummer etc.) mĂŒssen wir selbst generieren (evtl. ĂŒber das existierende PDF-Template-System aus dem CRM nutzen)

Provisioning-Flow (grober Entwurf):

  1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain)
  2. Admin-Portal: Trial-Instanz starten
    • DB erstellen, Docker-Container hochfahren, Caddy-Config fĂŒr Subdomain
    • Einladungs-Email mit Admin-Login + Passwort-Reset-Link
  3. Tag 25: Erinnerungs-Email "Deine Trial lÀuft bald ab"
  4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren"
  5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login
  6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv
  7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis)

Technische Bausteine fĂŒr spĂ€ter:

  • Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs)
  • Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt)
  • Docker-Orchestrierung: einzelne docker-compose.yml pro Kunde oder Docker-Swarm/K8s
  • Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups
  • Monitoring: ein Fail macht nicht alle down, aber wir mĂŒssen es mitbekommen
  • Logs zentral: z.B. Loki + Grafana fĂŒr aggregierte Logs aller Instanzen

Grobe ZeitschÀtzung:

  • Admin-Portal (MVP): ~1 Woche
  • GoCardless-Integration + Webhooks: ~3-5 Tage
  • Provisioning-Automatisierung (Docker + Caddy): ~1 Woche
  • Landing Page + Checkout: ~3-5 Tage
  • Tests + Polishing: ~1 Woche
  • Gesamt: ~3-4 Wochen

Vorbereitung JETZT (einfach, macht spÀter Arbeit leichter):

  • ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning)
  • ✅ Domain/Label dynamisch per Provider (schon erledigt)
  • Docker-Compose aufrĂ€umen, Env-Variablen dokumentieren (klein, ein Tag)
  • Backup-Script robust + wiederherstellbar (haben wir schon weitgehend)

✅ Erledigt

  • 🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung (2026-08-09)

    • Eine Sachwert-Gutschrift darf jetzt betragslos sein (Betrag leer/0): dann findet keine Rechnungsstellung statt – der Kunde hat den Gegenstand einfach als Subvention erhalten. BetrĂ€ge bleiben 0, keine USt.
    • Backend: amount leer → 0; Sachwert erlaubt 0, Geld verlangt Betrag > 0 (400 sonst). vatRelevant wird bei 0 erzwungen-false.
    • PDF: bei betragslosem Sachwert Titel „Sachwert-Übergabe", kein Betrags-/USt-Block (Hinweis „keine Rechnungsstellung") und kein ZUGFeRD- Embedding (es ist keine Rechnung). Mit Betrag → unverĂ€ndert ZUGFeRD.
    • Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet solange kein Betrag (Hinweis); Liste zeigt „Sachwert ohne Betrag (keine Rechnung)".
    • Verifiziert (Sachwert 0 → kein factur-x.xml; Geld 0 → 400; Sachwert 200 → ZUGFeRD).
  • 🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal (2026-08-07)

    • Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit previousContractId an – der Verlust passierte im Frontend: das „VorgĂ€nger-Vertrag"-Dropdown holte die VertrĂ€ge ĂŒber getAll, das DEACTIVATED standardmĂ€ĂŸig ausblendet. Beim Bearbeiten des Folgevertrags war der (deaktivierte) VorgĂ€nger daher nicht als Option vorhanden → beim Speichern ging die VerknĂŒpfung verloren. Fix: getAllContracts + Controller + contractApi.getAll um includeDeactivated erweitert; das VorgĂ€nger-Dropdown nutzt es (includeDeactivated: true) und markiert deaktivierte VertrĂ€ge mit „· deaktiviert". Verifiziert.
    • Kundendaten-Modal (Schnellansicht): zeigt jetzt zusĂ€tzlich die Lieferadresse und (falls abweichend) Rechnungsadresse des geöffneten Vertrags, die Stressfrei-Adresse des Vertrags (einzeln) und darunter alle weiteren Stressfrei-Adressen des Kunden. CustomerInfoModal nimmt optionale Props (deliveryAddress/billingAddress/contractStressfreiEmailId), Aufrufe aus ContractDetail + ContractForm ĂŒbergeben den Vertragskontext.
  • đŸš« Auto-KĂŒndigung + Cockpit-Filter KĂŒndigungsbestĂ€tigung (2026-08-07)

    • 1) Auto-Status bei KĂŒndigungsbestĂ€tigung: Wird zu einem Vertrag eine KĂŒndigungsbestĂ€tigung hinzugefĂŒgt – als Datum (cancellationConfirmationDate ĂŒbers Formular) und/oder als Dokument (Upload) – wird der Vertrag von ACTIVE → CANCELLED gesetzt und das Vertragsende = KĂŒndigungsdatum. Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent, nur aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei neu/geĂ€ndertem BestĂ€tigungsdatum, damit manuelle Status-Korrekturen nicht zurĂŒckgesetzt werden). Verifiziert.
    • 2) Cockpit-Filter „KĂŒndigungsbestĂ€tigung": listet alle VertrĂ€ge mit hinterlegter KĂŒndigungsbestĂ€tigung (Dokument und/oder Datum) in Status ACTIVE/DRAFT/CANCELLED/EXPIRED (Abgelaufen nachtrĂ€glich ergĂ€nzt). Eigene Liste cancellationConfirmations im Cockpit-Result (weil bereits CANCELLED-VertrĂ€ge mangels „Issue" sonst nicht auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert.
    • Nachbesserungen Pentest R138:
      • endDate wird bei DRAFT nicht mehr gesetzt (Entwurf = Vorlage); nur ACTIVE/CANCELLED bekommen Vertragsende = KĂŒndigungsdatum.
      • cancellationConfirmationDate/-OptionsDate im Update-Pfad ĂŒber validateOptionalIsoDate normalisiert → akzeptiert Datum-only UND volles ISO (vorher lehnte Prisma Datum-only ab), konsistent zum Upload.
    • Gutschrift-Dateien: Überweisungsbelege (credit-note-receipts) sind jetzt staff-only (neuer FileOwner-Typ contract-staff, Portal 403); das Gutschrift-PDF (credit-notes) bleibt vertragsbasiert (eigener Kunde darf seine Gutschrift laden). Anlegen/Beleg-Upload fĂŒr Portal war bereits per blockPortal/Portal-403 gesperrt.
      • Datei-Cleanup (R138-Hinweis): Beim Löschen einer Gutschrift werden PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr.
      • Hygiene R140: Datei-Lösch-Helfer in utils/fileCleanup.ts konsolidiert (deleteFileAbsolute + deleteUploadByRelativePath), ersetzt die vorher 3× kopierten deleteFileIfExists/cleanupFile (creditNote-, upload-, customer-Service). Reihenfolge umgestellt: erst DB-Delete/-Update, DANN Datei löschen (schlĂ€gt DB fehl, bleibt die Datei). Verifiziert.
  • [~] đŸ§Ÿ Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend (2026-08-06)

    • Use-Case: zu einem Vertrag kann eine Subvention gewĂ€hrt werden – Geld (Überweisung, EUR) oder Sachwert (Smartphone/Elektro 
, Textfeld). Betrag, Datum (Ă€nderbar), Ort/Unterschrift, bei Sachwert „Ware erhalten", bei Geld Überweisungsbeleg (Upload, Phase 2).
    • USt pro Gutschrift wĂ€hlbar (vatRelevant + Basis NETTO/BRUTTO + vatRate) → Netto/USt/Brutto werden berechnet & getrennt gespeichert (ZUGFeRD-tauglich). PRIVAT/FIRMA aus Kunde vorbelegt. Verifiziert: nicht-USt 200→200/0/200; NETTO 200→200/38/238; BRUTTO 200→168,07/31,93/200.
    • Nummernkreis (CreditNoteNumberRange, Einzel-Zeile, in Settings verwaltbar): PrĂ€fix/Jahr/Separator/Padding/StartzĂ€hler/Jahres-Reset. Nummernvergabe transaktional mit SELECT 
 FOR UPDATE (keine Doppel- vergabe). Beispiel GS-2026-0001.
    • Neu: Model CreditNote + CreditNoteNumberRange + Enums + Migration (IF NOT EXISTS, auf Dev angewandt). Service/Controller/Routes: GET/POST /contracts/:id/credit-notes, GET .../defaults, GET/PUT/DELETE /credit-notes/:id, GET/PUT /credit-notes/number-range. Portal-Token explizit geblockt (interner Bereich), CREATE/UPDATE/DELETE auditiert.
    • Phase 2a (erledigt): Kunden-Feld vatExempt (Kleinunternehmer §19, Checkbox nur fĂŒr Firmenkunden) + Migration. Gutschrift-Default vatRelevant wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst aus. Jede Gutschrift speichert ihren Snapshot → spĂ€terer Statuswechsel Ă€ndert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten".
    • Phase 2b (erledigt): CreditNotesSection im Vertragsdetail (eigene Section wie Rechnungen – die Vertragsansicht ist kartenbasiert, kein echter Tab-Balken; ggf. spĂ€ter umbaubar): Liste + Anlegen/Bearbeiten (Modal, Geld/Sachwert, USt-Block mit Live-Netto/USt/Brutto, Datum, Ort, Unterschrift, „Ware erhalten"), Überweisungsbeleg-Upload (nur GELD, ĂŒber /upload/credit-notes/:id/receipt, fileDownload-Ownership ergĂ€nzt). Nummernkreis-Verwaltung als Settings-Seite /settings/credit-note-number- range mit Live-Vorschau. Frontend-Typen + creditNoteApi.
    • Phase 3a (erledigt): CompanyProfile (Firmenstammdaten/Absender) + Migration + Settings-Seite /settings/company-profile.
    • Phase 3b Teil 1 (erledigt): Gutschrift-PDF (pdfkit) mit Absender aus CompanyProfile, EmpfĂ€nger, Netto/USt/Brutto, Sachwert/„Ware erhalten" bzw. Überweisungs-Bankverbindung, Unterschriftsblock, Fußzeile. Endpoint POST /credit-notes/:id/pdf, „PDF"-Button in der Section (Ansehen/Erzeugen). PDF-Pfad wird bei inhaltlicher Änderung geleert (Neu-Erzeugung). Lokal verifiziert (valides PDF).
    • Auszahlungskonto (nur GELD): CreditNote.payoutBankCardId + Migration (FK ON DELETE SET NULL). Dropdown im Formular mit ALLEN Bankkonten des Kunden (Default = Vertrags-Abbuchkonto), da die Auszahlung auf ein anderes Konto gehen kann. Server prĂŒft, dass die Bankkarte dem Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere Bankverbindung" + „an Bankkonto: ". Section-Zeile zeigt das Auszahlungskonto.
    • Phase 3b Teil 2 (erledigt): ZUGFeRD/Factur-X. zugferd.service.ts erzeugt CII-XML (EN 16931 urn:cen.eu:en16931:2017, Typ 381; Kategorie S bei USt, sonst E + Befreiungsgrund). zugferdPdf.service.ts bettet als hybrides PDF/A-3B ein: factur-x.xml (AF /Data), sRGB- OutputIntent (pdfkit-ICC ins Repo kopiert), XMP (pdfaid part=3/conf=B + Factur-X-Extension-Schema). PDF nutzt eingebettete DejaVuSans-Fonts (im Repo unter backend/assets/fonts, Pflicht fĂŒr PDF/A). Dockerfile kopiert backend/assets ins Runtime-Image. Lokal strukturell verifiziert (1 Seite, /AF, /Metadata, /OutputIntents, EmbeddedFiles, Font eingebettet, XML wohlgeformt, TypeCode 381, GrandTotal korrekt).
    • ⚠ VOR PROD: hybrides PDF gegen einen ZUGFeRD-/Factur-X-Validator prĂŒfen (am besten auf Staging mit echten Firmendaten). Feinheiten (Trailer-ID, XMP-Details, MIME text/xml vs application/xml) ggf. nach dem ersten Validator-Lauf nachziehen.
    • Offen (fachlich): USt-Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klĂ€ren – Modell/XML decken beide ĂŒber vatRelevant ab. ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen.
  • 📄➕ Vertrag kopieren (neuer eigenstĂ€ndiger Vertrag aus Vorlage) (2026-08-03)

    • „Kopieren"-Button in der Vertragsansicht (contracts:create) → öffnet das Neu-Formular unter /contracts/new?copyFrom=<id>&customerId=<cid> mit ALLEN Daten des Quellvertrags vorbefĂŒllt. Beim Speichern entsteht ein frischer, unabhĂ€ngiger Vertrag (kein previousContractId-Link, kein VVL).
    • Use-Case: neuer Mobilfunk-/o.Ă€. Vertrag ist meist fast identisch – nur Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif Ă€ndern sich.
    • Geleert im Kopier-Modus (IntegritĂ€t / Eindeutigkeit): Status→DRAFT, VorgĂ€nger-Link, alle Datumsfelder (Start/Ende/Installation/KĂŒndigungs- bestĂ€tigung), Kunden-/Vertragsnummer beim Anbieter + Plattform-Nummern. Nicht ĂŒbernommen: SIM-Karten & Rufnummern (hardware-/vertrags- spezifisch, eindeutige Nummern) – inkl. der Legacy-Scalar-Felder phoneNumber/simCardNumber (Pentest R135/136: die wurden zunĂ€chst noch kopiert → zwei getrennte VertrĂ€ge hĂ€tten dieselbe Rufnummer tragen können; jetzt im Leer-Block). Behalten wird der Portal-Zugang (Benutzername + Stressfrei-VerknĂŒpfung/„nicht benötigt") – bei gleichem Anbieter oft identisch; nur das verschlĂŒsselte Passwort wird nicht ins Feld geladen (wie beim Bearbeiten). Rest (Anbieter/Tarif, Preise, alle Detailfelder, Notizen) bleibt als Vorlage.
    • Rein Frontend (ContractForm copy-Mode + Button in ContractDetail); kein neuer Endpoint – nutzt bestehendes Create. Blauer Hinweis-Banner „Kopie 
 neuer eigenstĂ€ndiger Vertrag" im Formular.
  • [~] đŸ–±ïž PDF-Drag-and-Drop aus Bankkarte/Ausweis — VERWORFEN (2026-07-30)

    • Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer ziehen. Über mehrere Iterationen durchgespielt (Chromium-DownloadURL mit Access-Token → 60s-Download-Token → Blob/items.add(File)).
    • Ergebnis: harte Plattformgrenze. Ein Browser kann einer fremden Desktop-App keine echte lokale Datei zum AnhĂ€ngen ĂŒbergeben. URL-Drag → Thunderbird speichert nur einen Link (0 Bytes, lĂ€dt beim Senden nach → Fehler). Datei-Drop in den Nachrichtentext → nur Dateiname als Text. Auf die Anhang-Leiste gezogen (Thunderbird/Linux/Chromium) → ebenfalls kein echter Anhang. Webmail im Browser → gar keine Datei möglich.
    • Entscheidung (User): komplett wieder entfernt. Download- + Anzeigen- Button decken den Bedarf zuverlĂ€ssig ab. Komponente PdfDragButton gelöscht, fileUrl()-Token-Param zurĂŒckgebaut. Pentest R131 damit gegenstandslos (kein Drag-Feature mehr).
    • Bleibt erhalten: die separaten Copy-Buttons fĂŒr IBAN (Bankkarte) und Ausweisnummer im Vertragsformular (s.u.).
  • 📋 Vertragsformular: Copy-Buttons fĂŒr Bankkarte (IBAN) + Ausweis (2026-07-30)

    • In „Kundendaten verknĂŒpfen" je ein Kopieren-Button neben dem Label, kopiert nur den reinen Wert der aktuell gewĂ€hlten Option: bei Bankkarte die IBAN ohne Namen, bei Ausweis die Ausweisnummer ohne (TYP).
    • Button erscheint nur, wenn eine Karte/ein Ausweis ausgewĂ€hlt ist (watch('bankCardId')/watch('identityDocumentId') → Lookup).
  • 📧 Stressfrei-Mailbox: Passwort beim Anlegen verbindlich setzen (2026-07-30)

    • Beim Anlegen einer Adresse mit „Echte Mailbox erstellen (IMAP/SMTP)" wird jetzt immer ein frisch generiertes Passwort explizit am Provider gesetzt (updateMailboxPassword direkt nach dem Provisioning).
    • Behebt den Fall, dass die Adresse beim Provider bereits als reine Weiterleitung existierte: dann kehrte provisionEmailWithMailbox frĂŒh mit success zurĂŒck, ohne je ein Postfach-Passwort zu setzen – im CRM lag ein verschlĂŒsseltes Passwort, das der Provider nicht kannte → IMAP/SMTP- Login schlug fehl. Jetzt stimmen CRM und Provider garantiert ĂŒberein.
    • Nachtrag Pentest R130: Erster Versuch (updateMailboxPassword) setzte bei Plesk nur -passwd, ließ die Adresse aber auf -mailbox false → Mailbox nie aktiviert, Login scheiterte weiter. Jetzt enableMailboxForExistingEmail (-mailbox true -passwd ...), deckt beide FĂ€lle idempotent ab.
    • Tab-Leiste bei vielen Tabs: flex-wrap statt Overflow aus dem Rahmen.
    • Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag (s.u.).
  • đŸ€ Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)

    • Neuer Tab vor „Datenschutz", nur Mitarbeiter/Admin (nicht Portal), ohne Consent-Pflicht nutzbar. Zwei Abschnitte:
      1. „ wurde an Board geholt durch:" – max. 1 Werber (DB-Unique auf recruitedId).
      2. „ hat folgende Kunden an Board geholt:" – beliebig viele.
    • Jede Zeile: Kunde (per Lupe-Modal gesucht, breite Suche ĂŒber Name/Nr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown (Ehepartner/in, Kind, Freund/Kumpel, 
 „Bitte auswĂ€hlen!"). Löschen + Externtab-Link zur Kundenakte pro Zeile.
    • Bidirektional aus einem Datensatz: trĂ€gt man in Kunde A „geworben durch B" ein, erscheint A automatisch bei B unter „hat geworben". Von beiden Akten hinzufĂŒgbar/löschbar.
    • Neues Model CustomerReferral (recruiter/recruited FKs, recruitedId @unique, relationship) + Migration. Beziehungs-Whitelist server- seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE auditiert.
    • Nachtrag 2026-07-30: Beziehungs-Dropdown um Schwiegertochter/ Schwiegersohn, Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa erweitert (Whitelist front- & backend synchron). Bearbeiten-Stift pro Zeile (vor der MĂŒlltonne): Ă€ndert Beziehung und/oder Gegen-Kunden; neuer PUT /:customerId/referrals/:referralId (Whitelist, Doppel- Werber-409 mit Self-Ausschluss, Portal-Block, UPDATE auditiert).
  • 🔄 „Neue Version verfĂŒgbar"-Banner (offener-Tab-Problem)

    • Cache-Header waren schon optimal (index.html no-store, Assets immutable) – aber eine laufende SPA holt sich nach einem Deploy nicht von selbst neuen Code. Neuer useVersionCheck-Hook holt die (immer frische) index.html periodisch + beim ZurĂŒckkehren zum Tab, vergleicht die Vite-Asset-Hash-Signatur mit dem Startstand. Bei Änderung erscheint ein dezentes Banner „Neue Version verfĂŒgbar → Jetzt neu laden" (UpdateBanner, oben im Layout).
    • Kein Backend-/Build-Change nötig; im Dev-Modus automatisch No-op (keine /assets/<hash>-Dateien → leere Signatur).
  • 👁 Kundenportal: Toggle „Deaktivierte VertrĂ€ge anzeigen"

    • Der Admin-/Mitarbeiter-Blick (CustomerDetail) hatte den Toggle schon; jetzt auch in der Kundenportal-VertragsĂŒbersicht (ContractList, Portal-Zweig). Button nur bei isCustomerPortal, reicht includeDeactivated an die getTreeForCustomer-Queries durch (showDeactivated im Query-Key → frischer Fetch beim Umschalten).
    • Security: nutzt denselben Endpoint hinter canAccessCustomer (R120) – Portal-Kunde sieht nur eigene/vertretene BĂ€ume, das Flag weitet nur den Status-Filter innerhalb der erlaubten Daten. Keine neue Exposition.
  • 🔒 Mobilfunknetz: server-seitige Whitelist (Pentest-INFO)

    • mobileNetwork akzeptierte serverseitig jeden (stripHtml-bereinigten) String – das Frontend-Dropdown beschrĂ€nkte nur clientseitig. Jetzt Whitelist TELEKOM|VODAFONE|TELEFONICA in contract.service.ts (normalizeMobileNetwork), angewandt in Create- UND Update-Pfad (Update-Spread wurde vorher ungefiltert durchgereicht). Unbekannte/ leere Werte → null.
  • 🔒 Rate-Limiting: IPv6-Bypass-HĂ€rtung (ipKeyGenerator)

    • Die Rate-Limiter mit eigenem keyGenerator (loginRateLimiter, staffPasswordReAuthLimiter) keyten auf die volle req.ip. Bei IPv6 kann ein Angreifer aus seinem zugeteilten Block (/56–/64) pro Versuch eine neue Adresse nehmen und so das Per-IP-Limit (Login-Bruteforce, Passwort-Set-Reauth) umgehen.
    • Fix: req.ip in beiden keyGenerator durch ipKeyGenerator(...) (express-rate-limit v7) ersetzt → IPv6 wird auf das Subnetz normalisiert (Library-Default /56), IPv4 unverĂ€ndert. Verifiziert: zwei verschiedene IPv6 im selben /56 ⇒ derselbe Key.
    • Die Limiter OHNE eigenen keyGenerator (Passwort-Reset, Consent) machten das schon ĂŒber den Library-Default – jetzt konsistent. (Ersetzt den verworfenen aria-WIP; auf aktuellem Stand neu gebaut.)
  • 🐞 Spam-Tab: AnhĂ€nge aus Junk-Ordner (Pentest R124-Fund)

    • Beim Spam-Feature wurden moveEmailToTrash/restoreEmailFromTrash auf den echten Junk-Pfad umgestellt, aber vier Attachment-Funktionen im Controller (downloadAttachment, saveAttachmentTo, saveAttachmentAsInvoice, saveAttachmentAsContractDocument) bestimmten den IMAP-Ordner weiter hart als email.folder === 'SENT' ? 'Sent' : 'INBOX'. FĂŒr SPAM-Mails landete das fĂ€lschlich auf INBOX. Da IMAP-UIDs pro Ordner vergeben sind: 404 (Anhang nicht gefunden) oder – bei UID-Kollision – der falsche Anhang aus INBOX (z.B. als Rechnung/Vertragsdokument abgelegt). Kein Cross-Customer-Leak (bleibt im selben Postfach), aber echtes DatenintegritĂ€tsproblem.
    • Fix: an allen vier Stellen dieselbe Junk-Pfad-Logik wie in moveEmailToTrash + findJunkFolderPath importiert.
    • Randfall (Pentester-Nachtrag): findet sich der Junk-Ordner nicht (mehr), wird jetzt ein klarer Fehler zurĂŒckgegeben statt still auf INBOX/'Junk' zu raten – in allen 4 Controller-Funktionen (404 „Anhang nicht abrufbar") und den 2 Service-Funktionen (moveEmailToTrash/restoreEmailFromTrash). Kein undefined mehr Richtung IMAP-Lib.
  • 📧 E-Mail-Client: Spam-Ordner als eigener Tab

    • Neuer Tab „Spam" (zwischen Gesendet und Papierkorb) im E-Mail-Client, zeigt den Junk-/Spam-Ordner des gewĂ€hlten Postfachs – damit fĂ€lschlich als Spam eingestufte Mails auffindbar sind.
    • Backend: EmailFolder-Enum um SPAM erweitert (Migration 20260727110000_email_folder_spam, Enum-Wert angehĂ€ngt). IMAP-Sync ermittelt den Junk-Ordnerpfad per Special-Use-Flag \Junk + Namensliste (findJunkFolderPath), syncAllFoldersForAccount synct ihn zusĂ€tzlich als dbFolder=SPAM. getCachedEmails + getFolderCountsForAccount um SPAM erweitert.
    • Papierkorb-Verschieben/Wiederherstellen fĂŒr Spam-Mails nutzt den tatsĂ€chlichen Junk-Pfad als Quell-/Zielordner.
    • Frontend: Tab + Badge (ungelesen/gesamt), nutzt denselben List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam möglich (Rettung legitimer Mails).
  • đŸ“± Mobilfunk: Feld „Mobilfunknetz" unter Anbieter & Tarif

    • Neues Dropdown „Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur sichtbar wenn Vertragstyp = Mobilfunk. Optionen: Bitte auswĂ€hlen (leer), Telekom, Vodafone, TelefĂłnica.
    • Neues Feld MobileContractDetails.mobileNetwork (String, nullable, speichert TELEKOM/VODAFONE/TELEFONICA) + Migration 20260727100000_mobile_network (ADD COLUMN IF NOT EXISTS). String statt Enum, damit weitere Netze ohne Migration möglich sind.
    • Anzeige in der Vertragsansicht (Mobilfunk-Details) mit lesbarem Netz-Namen.
  • 🐞 LieferbestĂ€tigung: eingegebenes Datum Ă€ndert Vertragsbeginn nicht

    • Beim Upload einer LieferbestĂ€tigung mit Datum wurde startDate nur gesetzt, wenn es noch LEER war (if (!contract.startDate)). Hatte der Vertrag schon ein (geschĂ€tztes) Beginndatum, blieb es trotz eingetragenem Lieferdatum stehen.
    • Fix in maybeActivateOnDeliveryConfirmation: ein explizit eingegebenes Lieferdatum ĂŒberschreibt den Vertragsbeginn jetzt IMMER (die LieferbestĂ€tigung ist das maßgebliche tatsĂ€chliche Startdatum). Der Fallback „heute" (kein Datum eingegeben) fĂŒllt weiterhin nur ein leeres Feld, um ein echtes Datum nicht versehentlich zu ĂŒberschreiben. No-op + Audit-Log unverĂ€ndert. Frontend schickte das Datum bereits mit und lĂ€dt den Vertrag nach Upload neu – kein FE-Change nötig.
  • 🎂 Anstehende Geburtstage auch im Dashboard

    • Die Geburtstags-Sektion gab es bisher nur im Vertrags-Cockpit. Jetzt 1:1 auch auf dem Dashboard (nur Mitarbeiter/Admin, enabled: !isCustomer), gleiche birthdayApi.getUpcoming(7, 30)- Query + identische Karten-Darstellung (Heute/vergangen/kommend, Link auf die Kundenakte). Kein Backend-Change nötig.
  • 🔮 Pentest R121 – Audit-Verify: Fehlalarm „manipuliert" bei leerem resourceId

    • POST /api/audit-logs/verify meldete ~73% der EintrĂ€ge als manipuliert. Kein echtes Tampering, sondern ein Bug in generateHash: resourceId war beim Schreiben oft undefined (middleware-EintrĂ€ge ohne Route-ID). JSON.stringify LÄSST einen undefined-Wert weg → Hash ohne resourceId-Key. In der DB landet der Wert aber als NULL; verifyIntegrity/rehashAll lasen ihn als null zurĂŒck und JSON.stringify({resourceId:null}) schrieb ihn REIN → anderer Hash → Fehlalarm fĂŒr JEDE leere-resourceId-Zeile. Die vom Pentester gefundene durationMs-Korrelation war ein Proxy: middleware-EintrĂ€ge (durationMs gesetzt) haben oft kein resourceId.
    • Fix: generateHash lĂ€sst nullish resourceId weg – reproduziert exakt das historische Schreibverhalten. Kein Caller ĂŒbergibt je null explizit (verifiziert) → alle Bestands-Hashes matchen ohne Rehash. Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära (id>4141) 482/482 valide (vorher alle 289 null-Zeilen geflaggt).
    • Separat/vorbestehend: EintrĂ€ge VOR Commit fd55742 („complete new audit system") nutzen ein altes Hash-Schema und wurden nie neu baselined → scheitern unabhĂ€ngig davon. Remediation: einmalig POST /api/audit-logs/rehash (Admin). Nicht Teil dieses Fixes.
  • 🔧 Pentest R120 – Audit-Log /:id mit nicht-numerischer ID → 500 statt 400

    • Pentester stiess beim Suchen eines verify-integrity-Endpoints auf einen 500er. Ursache: GET /api/audit-logs/verify matcht GET /:id (das echte Verify ist POST /verify), parseInt("verify") = NaN → Prisma findUnique({ where:{ id: NaN }}) wirft → 500. Gleiche „400 statt 500"-Klasse wie R64.1/R104.1.
    • Fix: Number.isNaN-Guard in getAuditLogById, getAuditLogsByCustomer und updateRetentionPolicy → sauberer 400.
  • 🔮 Pentest R120 – CRITICAL IDOR: Vertragsbaum fremder Kunden lesbar

    • GET /api/contracts?tree=true&customerId=<fremd> returnte fĂŒr Portal-User den vollstĂ€ndigen Vertragsbaum eines beliebigen Fremdkunden (Name, Kundennummer, Vertragsnummern, Tarife). Der tree=true-Zweig im Controller returnte frĂŒh, bevor die Portal-User-customerIds-Filterung unten fĂŒr die flache Liste griff. Vorbestehender Bug – der Toggle „Deaktivierte anzeigen" (siehe unten) hat ihn nur sichtbarer gemacht, weil selbst archivierte FremdvertrĂ€ge mit auftauchten.
    • Fix: canAccessCustomer(req, res, customerId) vor dem frĂŒhen Return (contract.controller.ts:80). PrĂŒft eigene Customer-ID + vertretene MIT Live-Vollmacht, sendet selbst die 403. Staff (Nicht-Portal) passiert unverĂ€ndert. Gleiches Muster wie Pentest 56.3 bei update/delete.
    • Zusatzbefund (Pentester): liegen gebliebenes <script>-Test- Artefakt in providerName/tariffName eines Staging-Vertrags. Nicht exploitierbar – keine der 9 dangerouslySetInnerHTML-Stellen rendert Vertrags-Provider/Tarif-Namen, React escaped sie als Text. Neue Writes werden zusĂ€tzlich per sanitizeContractBody (stripHtml) entschĂ€rft; der Altwert stammt aus DB-Direkteingabe.
    • Nachtrag (Retest): Der abgewehrte Zugriff wird nun in ZWEI Streams protokolliert. Bisher nur SecurityEvent (ACCESS_DENIED, sichtbar unter /api/monitoring/events, aber löschbar + nicht hash-verkettet) – der Pentester suchte im AuditLog und fand nichts. emitAccessDenied schreibt jetzt zusĂ€tzlich einen tamper-evidenten AuditLog-Eintrag (action: READ, resourceType: 'AccessDenied', Sensitivity HIGH) fĂŒr alle canAccess*-403. Meine ursprĂŒngliche Formulierung „landet im Audit" war die falsche Tabelle – jetzt stimmt sie.
  • 👁 Kundenansicht: Toggle „Deaktivierte VertrĂ€ge anzeigen"

    • Der Vertragsbaum beim Kunden (CustomerDetail → Tab VertrĂ€ge) blendete DEACTIVATED-VertrĂ€ge komplett aus. Da der jeweils aktuellste Vertrag die Baumwurzel ist und VorgĂ€nger als Children hĂ€ngen, verschwand eine ganze Kette aus der Ansicht, sobald die Wurzel deaktiviert wurde – genau das passierte, als ein aktiver Folgevertrag gelöscht und der VorgĂ€nger vorher auf „deaktiviert" gesetzt worden war. Der Vertrag „verschwand" scheinbar, war aber nur unsichtbar gefiltert.
    • Fix: getContractTreeForCustomer(customerId, includeDeactivated) mit optionalem Flag (Default false = bisheriges Verhalten). Query- Param includeDeactivated=true, Toggle-Button im Kunden-Vertragstab (Eye/EyeOff). showDeactivated ist Teil des Query-Keys → frischer Fetch beim Umschalten. Deaktivierte VertrĂ€ge tragen ihr graues DEACTIVATED-Badge, bleiben also klar erkennbar.
  • 🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht

    • Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen des Folgevertrags an der @unique-Regel auf Contract.previousContractId – wenn A → B → C stand und B gelöscht wurde, hielt B im UPDATE-Moment noch selbst previousContractId=A, C sollte auch auf A → Duplicate- Entry, das Update brach ab. (2) Der Frontend-deleteMutation in ContractDetail invalidierte die Contract-Listen-Query nicht, navigierte nur nach /contracts und zeigte den alten Cache. Zusammen sah es aus, als wĂ€re die ganze Historie weg – tatsĂ€chlich stand der VorgĂ€nger noch im Backend.
    • Fix Service: in einer Transaktion erst B löschen (DB-Cascade rĂ€umt C.previousContractId auf NULL und gibt den A-Slot frei), dann C sauber auf A umbiegen. Kein Zwischenzustand mehr sichtbar.
    • Fix Frontend: queryClient.invalidateQueries(['contracts']) plus ['contract-tree', customerId] und ['customer', customerId] nach dem Delete. onError-Toast ergĂ€nzt, damit fehlgeschlagene Löschungen nicht mehr still verschwinden.
  • 📝 DSGVO-Audit: Portaldaten-Opt-out als eigenes CRITICAL-Event

    • Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des portalCredentialsNotRequired-Flags emittiert jetzt zusĂ€tzlich zum generischen Contract-Update-Diff ein dediziertes UPDATE-Event unter resourceType: 'ContractPassword' — landet damit in derselben CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads.
    • Motivation: das Setzen des Flags rĂ€umt server-seitig portalUsername/portalPasswordEncrypted/stressfreiEmailId auf NULL. Diese Löschung sensitiver Daten ist jetzt unabhĂ€ngig vom generischen Diff nachweisbar. RĂŒcknahme wird ebenfalls geloggt.
    • details enthĂ€lt alte + neue Flag-Werte plus Bool-Marker, welche Anmeldedaten vor dem Opt-out belegt waren (keine Klartext-Leckage).
  • 🔕 Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Opt-out

    • Dritter Radio-Button in der Zugangsdaten-Card des Vertragsformulars: „Nicht möglich zu bekommen / nicht benötigt". Wenn gesetzt, unterdrĂŒckt das Cockpit die Warnung „Portal-Zugangsdaten fehlen" fĂŒr diesen Vertrag (Anbieter hat kein Portal / Kunde bekommt keine Zugangsdaten).
    • Neues Feld Contract.portalCredentialsNotRequired (Boolean, default false) + Migration 20260717100000_contract_portal_credentials_not_required (ADD COLUMN IF NOT EXISTS). Bestand bleibt unverĂ€ndert warnend.
    • Passwort-Spalte im Formular wird bei Opt-out ausgeblendet; alte Username-/Passwort-Werte werden beim Umschalten geleert, damit der Vertrag nach dem Speichern nicht mit Leichen zurĂŒckkommt.
  • 🔧 Debug-Hilfe: Fehler bei „PIN/PUK anzeigen" sichtbar machen

    • Der stumme alert('PIN/PUK konnte nicht geladen werden') maskierte die eigentliche Ursache. Ersetzt durch Toast mit Backend-Message (Vertrag nicht gefunden / Kein Zugriff / Decrypt-Fehler), console.error mit Stacktrace und einem Info-Toast, falls die SIM in der DB gar keine PIN/PUK hinterlegt hat. Kein Verhaltens-Change am Endpoint – nur Diagnose.
  • 🔗 Vertrag-Zugangsdaten: Externtab-Link zu Kunden-Adressen

    • Kleines Externtab-Icon neben dem Label „Portal Benutzername", öffnet /customers/{id}?tab=stressfrei in neuem Tab, damit man beim Anlegen einer Portal-Wechseladresse nicht durch die Vertragsansicht zurĂŒck navigieren muss. Sichtbar in beiden Modi.
    • Als R116 vom Pentester geprĂŒft, kein Finding (SPA-Parameter, Backend wertet ?tab= nicht aus; Sub-Route-IDOR-Check auf canAccessCustomer steht).
  • 🔧 Pentest R111 – contract-categories: falscher Permission-String

    • Der Pentester meldete 403 auf POST/PUT/DELETE /api/contract-categories/* fĂŒr Admin trotz vorhandener contract-categories:*-Permissions. Vermutete Regression aus R110, war aber schon seit dem ersten Commit so – die Route verlangte developer:access, wĂ€hrend der Permission-Seed den Admins contract-categories:* gibt.
    • Fix: Route auf contract-categories:create/update/delete umgestellt, konsistent mit den sechs Peer-Katalog-Endpunkten (platforms/tariffs/cancellation-periods/
). Kommentar aktualisiert, damit die historische Design-Restriktion nicht wieder eingebaut wird.
  • 🔒 Pentest R110 – Mass-Assignment-Whitelist auf 7 Update-Endpunkten

    • MEDIUM-Finding: 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 M1 (Settings Mass Assignment). Nachgewiesen war es via provisionError-Feld ausserhalb des TS-Types.
    • Fix: sieben Whitelists + pickXxxUpdate()-Helper in sanitize.ts, in den jeweiligen Controllern eingehĂ€ngt. Nur die vom Service- Interface deklarierten Felder passieren.
    • EmailProvider: bewusst ohne stripHtmlFromStrings, weil das Passwörter/API-Keys mit Sonderzeichen mutiliert hĂ€tte.
  • 🐞 KĂŒndigungsdatum: Cursor sprang beim Tippen aus dem Feld

    • <input type=date> feuerte onChange bei jedem Tastendruck; sobald z.B. 18.08.0002 ein gĂŒltiges Datum ergab, feuerte die PUT-Mutation, Server-Antwort re-renderte den Input, Cursor sprang raus, Seite scrollte, weiteres Tippen landete im nĂ€chsten Feld.
    • Fix: neue AutosaveDateInput-Komponente hĂ€lt den Wert wĂ€hrend der Eingabe lokal, feuert onSave erst bei Blur/Enter. Escape verwirft. Angewendet auf beide KĂŒndigungsbestĂ€tigungs-Datumsfelder in ContractDetail. Andere App-Datumsfelder laufen ĂŒber react-hook-form (batched Submit) und sind nicht betroffen.
  • 🐞 IMAP-Fehler: „Command failed" ohne Kontext

    • imapflow wirft bei jedem IMAP-NO/BAD-Response nur new Error('Command failed') und legt Details in .responseText/ .responseStatus ab. Wir haben sie nirgends gelesen → User sah im Sync-Toast und im Anhang-Download nur "Command failed" ohne Ursache. Realer Fall: abgelaufenes Postfach-Passwort.
    • Fix: zentraler humanizeImapError()-Helper in imapService.ts. Extrahiert responseText/responseStatus, erkennt Auth-Fehler, UID-Stale, TLS-/Netzwerk-Fehler und gibt jeweils eine klare Meldung mit Handlungsanweisung zurĂŒck. Angewendet auf fetchAttachmentInner, downloadAttachment-Controller und syncEmailsForAccount.
  • 🆕 Bankkarte-Beschreibung in Vertragsansicht + Auswahl

    • Nach dem Anlegen des description-Feldes (Bankkarten-Modal) wird die Beschreibung jetzt auch in der Vertragsansicht (Bankkarten-Card) und im ContractForm-Select angezeigt. Im Select mit Whitespace-Flatten und 60-Zeichen-Cap, damit lange Notizen den Select nicht sprengen.
  • 🔒 Pentest R104.1 – Salutation-Endpunkte: 404 statt 500

    • INFO-Finding: GET/PUT /api/customers/:id/salutation-preference warfen bei nicht-existierendem Customer 500 statt 404.
    • Ursache: canAccessCustomer prĂŒft fĂŒr Staff-User nur Portal-Flag, kein Existenz-Check. Service warf new Error(...), Controller- Catch mappte generisch auf 500.
    • Fix: neuer Helper assertCustomerExists() im Service wirft ApiError(404, ...). Die drei Controller-Catches respektieren jetzt ApiError.statusCode. Auch set/clear prĂŒfen Existenz (sonst kĂ€me ein Prisma-P2003 als 500 durch).
  • 🔒 Pentest R102 – Interne Vertragsnummer nachziehen

    • INFO-Finding: contractNumber (interne CRM-Nummer) hatte im Update-Path keine Whitelist. <script>alert(1)</script> → stripHtml → leerer String → Vertragsnummer silent ĂŒberschrieben.
    • Fix: contractNumber zu CONTRACT_IDENTIFIER_FIELDS hinzugefĂŒgt – bestehende R86-Whitelist (^[A-Za-z0-9_\-/. ]{0,100}$) greift automatisch, R87-Fix (Raw-Input vor stripHtml) ist auch drin. Legitime GAS-MPVJ4P10FE8 bleibt OK, alle Angriffs-Payloads → 400.
    • Doku in SECURITY-HARDENING.md § Runde 102.
  • 🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log

    • Pentester R101.1 (INFO/funktional) berichtet: ?disposition=inline bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im fileDownload.controller ist eigentlich korrekt – sauberer Magic- Byte-Check fĂŒr PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest gegen echte Vertrags-PDFs application/pdf. Wir können das in Prod aber nicht reproduzieren.
    • Refaktorierung: Magic-Byte-Check in detectSafeContentType() extrahiert, finally-Block schließt File-Descriptor garantiert, Short-Read-FĂ€lle (bytesRead < n) jetzt sauber geguardet.
    • Sicherheits-Verhalten unverĂ€ndert: bei Magic-Byte-Mismatch bleibt es bei Content-Disposition: attachment (Stored-XSS-Schutz aus R30.13).
    • Neu: console.warn, wenn inline angefragt wurde, aber der Magic-Byte-Check fehlschlĂ€gt oder der Read crasht. Damit fĂ€llt der Fall im Prod-Log auf, falls er nochmal auftritt – bisher war's silent.
  • 🔒 Pentest R97 – Attachment-Validierung im Send-Handler

    • R97.1 (LOW): malformed content (null, fehlend, true, "") erzeugte 200/500 mit rohem Buffer.from()-Fehlertext in der Response. content: "" ließ sogar eine Mail mit 0-Byte-Anhang durchgehen.
    • R97.2 (INFO): keine App-Level-Caps (GrĂ¶ĂŸe + Anzahl) – die im Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am bodyParser; falls der je hochgedreht wird, fĂ€llt die Sicherung.
    • Fix: validateAttachments() im Controller sendEmailFromAccount lĂ€uft vor dem sendEmail-Aufruf:
      • attachments muss Array oder undefined sein
      • max 25 AnhĂ€nge
      • jeder: filename non-empty String, content non-empty Base64- String (Regex), optional contentType String
      • max 10 MB/Datei, 25 MB gesamt (SchĂ€tzung via base64-LĂ€nge × 0.75, kein Buffer.from-Aufruf wĂ€hrend der Validierung)
    • Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases grĂŒn inkl. aller R97.1-Pentest-Payloads.
  • 🆕 E-Mail-Compose: Vertragsdokumente anhĂ€ngen + Kundendaten einfĂŒgen

    • Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben "Datei anhĂ€ngen":
      • Vertragsdokumente: listet alle am Vertrag gespeicherten Dokumente gruppiert nach documentType. Auswahl → Server-Download via fileUrl (Token-Auth, Per-File-Ownership-Check greift) → base64 → direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit.
      • Kundendaten einfĂŒgen: zeigt nur Sections die tatsĂ€chlich Daten haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag, Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank + Ausweis zusĂ€tzlich Sub-Checkbox "als PDF anhĂ€ngen", wenn ein documentPath vorhanden ist.
    • Beim BestĂ€tigen werden die Text-Blöcke an das Body-Ende gehĂ€ngt (mit \n\n-Separator), AnhĂ€nge per serverFileToAttachment aus composeAttachmentHelpers.ts gezogen. Anhang-Limit (25 MB gesamt) wird beidseitig geprĂŒft, drĂŒberlaufende Dateien werden mit Toast ĂŒbersprungen statt silent weggeschluckt.
    • Helpers (composeAttachmentHelpers.ts):
      • serverFileToAttachment(path, filename) – fetch via Token-URL → Blob → base64 → EmailAttachment.
      • totalAttachmentBytes – GrĂ¶ĂŸen-Check unter BerĂŒcksichtigung der ~33 % base64-Overhead.
      • bankCardAttachmentName / identityDocAttachmentName – sprechende Dateinamen fĂŒr den EmpfĂ€nger.
  • 🔒 Pentest R95 – Portal-Username (Manual-Modus) hĂ€rten

    • R95.1 (MEDIUM): foo\r\nBcc:evil@x.de → Header-Injection-Vektor sobald der Wert in Mail-Templates / PDF-Footer landet.
    • R95.3 (LOW): <script>
</script>@x.de → silent stripHtml-Mutation (R87.1-Pattern, dritter Treffer auf demselben Bug).
    • 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 sauber. Raw-Input direkt validiert (R87-Pattern). EingehĂ€ngt in sanitizeContractBody.
    • Frontend: maxLength={100} am Input.
    • R95.2 bewusst nicht ĂŒbernommen (Email-Format-Pflicht): das Feld ist im Manual-Modus nicht zwingend eine E-Mail – Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als Portal-Login. Doku in SECURITY-HARDENING.md § Runde 95.
  • 🔒 Pentest R93 – Leerer String != fehlender Param

    • R93.1 (INFO): ?accountId= (explizit-leer) wurde wie ?accountId weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten. Pentester-Spec: leerer String ist KEINE gĂŒltige Zahl.
    • Fix im parsePositiveIntQuery()-Helper: striktere Absent-Logik (v === undefined ist absent; '', ' ', alles andere muss parsen). Required + optional Modes unverĂ€ndert.
    • Float-Grenzfall (accountId=5.5 → 5 via parseInt) bleibt als by-design akzeptiert (Pentester-BestĂ€tigung, kein Security-Impact).
  • 🔒 Pentest R92 – Strict-400 fĂŒr accountId auf Vertrags-Endpunkten

    • R91-Fix war silent-undefined bei invaliden Werten: accountId=abc auf GET /contracts/:id/emails ergab "kein Filter" → Mailbox- Isolation brach (alle PostfĂ€cher sichtbar). Pentester R92: per Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400.
    • Fix: parsePositiveIntQuery(v, label, res, { required? }) ersetzt den alten silent-Helper. Modes:
      • default (optional): fehlend/leer → undefined (kein Filter), invalid → 400
      • { required: true }: fehlend/leer oder invalid → 400
    • Verteilung:
      • Contract-Emails, Contract-Folder-Counts: { required: true }
      • Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox- View ist legitim), invalid → 400
    • Frontend hat schon ein enabled: !!selectedAccountId-Guard auf den Vertrags-Queries – kein UX-Bruch.
  • 🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param

    • R91.1 (LOW): accountId=abc → parseInt('abc') = NaN → der Ternary im Controller gab NaN an den Service, if (NaN) ist falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit ungĂŒltigem accountId sah alle Mailbox-Mails fĂŒr seinen Vertrag statt nur die aus dem gewĂ€hlten Postfach (kein Cross-Customer- Leak — canAccessContract greift weiter).
    • Fix: zentraler parsePositiveIntParam() im cachedEmail.controller.ts, der nur positive Ganzzahlen aus dem Query-String akzeptiert und alles andere zu undefined macht. Eingesetzt in allen 5 Endpunkten, die accountId/contractId aus Query nehmen (Contract-Emails, Contract-Folder-Counts, Customer-Emails, Trash, Trash-Count) – auch da, wo der Pentester nicht getestet hat, weil derselbe Pattern ĂŒberall stand.
  • 🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht

    • Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN PostfĂ€chern des Kunden angezeigt, egal welches Postfach im Selector aktiv war. Im Vertrag fehlte zusĂ€tzlich der Vertrags-Filter fĂŒr den Papierkorb.
    • Backend:
      • getEmailsForContract controller akzeptiert jetzt accountId- Query-Param und reicht ihn als stressfreiEmailId an getCachedEmails weiter (der hat den Filter eh schon implementiert, nur niemand hat ihn aufgerufen).
      • getTrashEmails (controller + service) akzeptiert accountId und contractId als optionale Filter. Default-Verhalten unverĂ€ndert, wenn keiner gesetzt ist.
      • getFolderCountsForContract akzeptiert optional stressfreiEmailId, bekommt zusĂ€tzlich trash + trashUnread ins Result – sonst lĂ€ge der Trash-Badge im Vertrag wieder account-global, wĂ€hrend die Liste contract-scoped ist.
    • Frontend:
      • cachedEmailApi.getForContract / getTrash / getContractFolderCounts nehmen den Filter entgegen.
      • ContractEmailsSection reicht selectedAccountId in alle drei Queries durch und nimmt es in den queryKey mit auf – sonst greift der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge kommt jetzt aus den contract-scoped Counts, damit Badge und Liste synchron laufen.
      • EmailClientTab reicht selectedAccountId in die Trash-Query durch (Inbox/Sent waren schon korrekt).
  • 🔒 Pentest R89 – Provider-Adressfelder hĂ€rten

    • R89.1 (MEDIUM): sanitizeNotes(
, 500) macht silent slice(0, 500) statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit 200 OK gespeichert.
    • R89.2 (LOW): stripHtml lief vor dem Length-Check – <script>
</script> reduzierte auf leeren String → null in der DB → vorheriger Wert silent ĂŒberschrieben (R87.1-Pattern auf Adress-Feldern).
    • Fix: eigener validateProviderAddress() in sanitize.ts. Raw-Input, max 500 → ApiError(400), Blacklist <, >, Tab, alle Control- Chars außer \n. CRLF → LF normalisiert vor Length-Check. EingehĂ€ngt in stripProviderStrings.
    • R89.3 (Quotes) + R89.4 (\n): bewusst nicht gefixt – Pentester bestĂ€tigt "kein unmittelbares Risiko", React escaped korrekt, sind legitime Bestandteile mehrzeiliger Postadressen.
    • Doku in SECURITY-HARDENING.md § Runde 89.
  • 🆕 Anbieter: Kontakt + KĂŒndigung als Stammdaten

    • Sieben neue optionale Felder am Provider-Modell: contactEmail, contactPhone, contactFax, contactAddress, cancellationEmail, cancellationFax, cancellationAddress. Postadressen als TEXT (mehrzeilig), Rest VARCHAR(191). Migration 20260621100000_provider_contact_and_cancellation mit IF NOT EXISTS.
    • Modal „Anbieter bearbeiten" bekommt eine neue Sektion Kontakt & KĂŒndigung unterhalb der Auto-Login-Felder, getrennt in zwei Untergruppen (Kontakt / KĂŒndigung) mit kleinen Headern. Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen als <textarea rows={3}> mit maxLength={500}.
    • Backend-Validierung: contactEmail/cancellationEmail laufen durch isValidEmail (Header-Injection-Schutz fĂŒr Mail-Templates), contactPhone/contactFax/cancellationFax durch sanitizePhoneField (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit 500-Cap.
    • Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen Felder beim Backup/Restore verloren.
  • 🔒 Pentest R87 – Whitelist vor Sanitizer (silent-mutation-Schutz)

    • R87.1 (LOW): stripHtml lief im R86-Fix VOR der Whitelist. Tags wurden still weggestrippt → 200 OK mit mutierten Werten, <script>
</script> reduzierte auf leeren String → null in der DB → vorheriger Wert ohne Fehlermeldung ĂŒberschrieben.
    • Fix: Validierungs-Reihenfolge fĂŒr die fĂŒnf Identifier-Felder umgedreht – validateContractIdentifier lĂ€uft jetzt direkt gegen den Raw-Input. Die strikte Whitelist lehnt eh alles ab, was stripHtml normalerweise auffangen wĂŒrde (Tags, Schemes, Zero-Width, Homoglyphe, Percent-Encoding) – Defense- in-Depth bleibt, nur ehrlich (400 statt silent-200).
    • Single-Line-Patch in contract.controller.ts, Doku in SECURITY-HARDENING.md § Runde 87.
  • 🔒 Pentest R86 – Vertrags-Identifier hĂ€rten

    • R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/ Auftragsnummer warfen 500 (DB-Overflow VARCHAR(191)) statt 400.
    • R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag (foo" onerror=
) ĂŒberlebte stripHtml – kein Risiko in der React- UI, aber relevant fĂŒr PDF/Mail/CSV-Export.
    • Fix: zentraler validateContractIdentifier() in sanitize.ts mit Max-100 und Whitelist ^[A-Za-z0-9_\-/. ]{0,100}$. Bewusst literales Space statt \s, damit kein CRLF/Tab passiert (Header- Injection). Wirft ApiError(400, 
) mit klarer Meldung.
    • EingehĂ€ngt in sanitizeContractBody → lĂ€uft automatisch fĂŒr alle fĂŒnf Identifier-Felder bei Create/Update. ContractForm bekommt maxLength={100} als UX-Schicht. Doku in docs/SECURITY-HARDENING.md § Runde 86.
  • 🆕 Vertrag: Auftragsnummer bei Vertriebsplattform

    • Neues optionales Feld Contract.orderNumberAtSalesPlatform (VARCHAR(191) NULL), Migration 20260619100000_contract_order_number_at_sales_platform mit IF NOT EXISTS.
    • Im ContractForm direkt vor der Kundennummer der Vertriebsplattform angeordnet (Wunsch des Users). ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor den anderen beiden Sales-Platform-Feldern.
    • Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und XSS-Strip-Allowlist (CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen, damit das neue Feld die gleichen Garantien wie Kunden-/Vertragsnummer bekommt.
  • 🐞 Entfernte Weiterleitungen kamen via Auto-Import zurĂŒck

    • Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kĂŒrzer → Plesk-Sync lĂ€uft → Auto-Import (Pentest 83.x) sieht „c ist in Plesk aber nicht in DB" → schreibt c zurĂŒck in additionalForwardingEmails → Diff sagt nichts zu entfernen.
    • Ursache: Auto-Import war fĂŒr alle Sync-Aufrufe aktiv. Beim Sync-Button-Klick will der User Plesk-Bestand ĂŒbernehmen (Import sinnvoll), beim Add/Remove im Modal ist die DB-Liste die explizite Intent (Import schĂ€dlich).
    • Fix: syncForwardingForEmail(id, { autoImportPleskMembers? }) mit Default true. setAdditionalForwards ruft mit false auf → entfernte Adressen verschwinden jetzt sauber bei Plesk. Sync-Button-Pfad bleibt unverĂ€ndert (importiert weiterhin alte Bestands-Members).
  • 🐞 Plesk-Sync: -forwarding-addresses set: existiert gar nicht

    • Folge-Bug nach a83358b/24e152b: Sync verĂ€ndert Plesk weiterhin nicht. plesk bin mail --help zeigt: -forwarding-addresses akzeptiert ausschließlich add: und del: – unser set: wurde von Plesk silent verworfen. Außerdem gibt es keine separate -mailgroup-Option; was Plesk im --info als Mailgroup: true zeigt, ist genau das, was -forwarding true in der CLI setzt (doppelt benannt). Mein vorheriges -mailgroup false lief auf den Phantom-Parameter und triggerte Unrecognized option.
    • updateForwardTargets baut jetzt den Diff: aktuelle Mailgroup- Members (aus emailExists) gegen Soll-Liste; del:<entfernt> + add:<neu> in zwei separaten CLI-Calls. Idempotent. Case-insensitive – Bruns.Gerhard ≡ bruns.gerhard.
    • Phantom--mailgroup-Parameter entfernt.
    • Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag): nichts entfernt, nur bzirks@gmx.de hinzugefĂŒgt.
  • 🔒 Pentest 83.1-83.3: Auto-Import-Pfad hĂ€rten

    • 83.1 MEDIUM: Auto-Import in syncForwardingForEmail umging assertValidForwardingEmail. Plesk-Member wie attacker@plesk.internal oder evil@x.local wĂ€ren ohne TLD-Block-Check (71.1) in unsere DB gewandert. Fix: jeder importierte Member lĂ€uft durch assertValidForwardingEmail; ungĂŒltige werden silent gedroppt und auf console.debug-Level geloggt.
    • 83.2 LOW: Self-Forward-Schutz (81.1) lief nur im Add-Pfad. Wenn Plesk die eigene Adresse als Mailgroup-Member fĂŒhrte, wĂ€re sie beim Auto-Import in die DB-Liste gerutscht → nach dem Umschalten auf Forwarding Mail-Loop. Fix: seenKeys.add(canonicalEmailKey(stressfreiEmail.email)) vor der Import-Schleife.
    • 83.3 INFO: PII-Log auf console.debug umgestellt (statt console.log auf Default-Level).
    • Smoke-Test mit gemischter Plesk-Liste: karibik61@web.de (legit) importiert, attacker@plesk.internal + evil@x.local per 83.1 abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2 abgelehnt, Customer-Stamm-Mail + Default deduped.
  • 🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht

    • Prod-Bug: User trĂ€gt zusĂ€tzliche Weiterleitung ein, Toast meldet Erfolg, aber Plesk ĂŒbernimmt nichts. Ursache: Plesk hat zwei Verteil-Mechanismen, Mailgroup (alte CLI-Anlagen, Group member(s):) und Forwarding (Forward request:). Unser Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup → unsere set:-Befehle landeten in einer ungenutzten Tabelle. Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt wurden (Forwarding-Modus von Anfang an).
    • EmailExistsResult um mailgroupActive + mailgroupMembers + forwardingActive + forwardingTargets erweitert.
    • pleskProvider.emailExists parst alle vier Felder aus dem --info-stdout (Mailgroup: true|false, Group member(s): ..., Forward request: ...).
    • pleskProvider.updateForwardTargets setzt jetzt zusĂ€tzlich -mailgroup false, damit der Legacy-Mechanismus deaktiviert wird und nur noch Forwarding aktiv ist.
    • syncForwardingForEmail: vor dem Plesk-Update werden bestehende Mailgroup-Members + Forwarding-Targets abgeholt und in unsere additionalForwardingEmails-Liste importiert (canonical-Key- Dedup). Verlustfrei – kein bestehender EmpfĂ€nger fĂ€llt beim Umschalten auf Forwarding raus. Import-Fehler werden geloggt, aber der eigentliche Sync lĂ€uft trotzdem.
  • 🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider

    • Bug: User konnte die Stressfrei-Adresse selbst (z.B. max.mustermann@stressfrei-wechseln.net) als zusĂ€tzliches Weiterleitungsziel eintragen – auch Plus-Varianten davon. Plesk leitet auf sich selbst um → Mail-Loop.
    • Backend (setAdditionalForwards): zieht jetzt zusĂ€tzlich meta.email aus der DB und vergleicht canonicalEmailKey(eintrag) gegen canonicalEmailKey(meta.email). Bei Treffer hartes ApiError(400) mit klarer Self-Forward-Meldung statt silent dedup – der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
    • Frontend (AdditionalForwardsModal): zusĂ€tzlich proaktive Validierung im Sub-Modal mit identischem canonicalize-Helper (Plus-Tag strippen, lowercase). Neuer Prop selfEmail, damit auch der Create-Modus (vor dem Persistieren) den Check fahren kann. Spart einen Roundtrip + zeigt sofort eine sprechende Meldung „
 zeigt auf die Adresse selbst – Mail-Loop".
  • 🔒 Pentest 77.3 (LOW): requireIdParam ließ Float-IDs durch

    • Number.isInteger(parseInt('4.5')) ist true, weil parseInt den Nachkomma-Teil silent abschneidet. Damit traf /.../4.5/... auf die echte ID 4 statt 400 zurĂŒckzuliefern. Gleiches gilt fĂŒr 4.0 und Exp-Notation 4e1.
    • Fix: vorm Parsen Regex /^\d+$/ auf die rohe req.params.<name>- Eingabe. Nur reine Ziffern erlaubt – keine Floats, Exp, Vorzeichen, Whitespace, Hex.
    • Smoke-Test (17 Cases): 4.0, 4.5, 4e1, 4E2, 0, -4, +4, 0x10, 1.0e0, leading/trailing Space alle abgelehnt; 1, 4, 100, 9999999 durchgewunken.
  • 🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt

    • Bug: User konnte dieselbe Adresse zweimal beim selben Kunden anlegen (siehe Screenshot mit 2× max.mustermann@...). createEmail hatte keinen Duplikatscheck, updateEmail ebenfalls nicht.
    • Service: Vor prisma.create jetzt findFirst auf (customerId, email) (case-insensitive). Bei Treffer → ApiError(409). Unterschiedliche Meldung fĂŒr aktive vs. inaktive Duplikate (Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
    • updateEmail: gleicher Check beim Umbenennen, mit NOT id-Exclude.
    • Controller: catch-Blöcke honorieren jetzt den ApiError.statusCode (vorher pauschal 400) → 409 kommt sauber durch.
    • Frontend: updateMutation bekam ein onError, damit der 409 nicht nur ins Leere lief.
  • 🔒 Pentest 71.1–71.4: HĂ€rtung der Zusatz-Weiterleitungen

    • 71.1 MEDIUM: Reservierte/private TLDs (local, internal, corp, lan, home, private, invalid, test, localhost, example, intranet, localdomain, arpa) werden in assertValidForwardingEmail jetzt hart abgelehnt. Verhindert Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
    • 71.2 LOW: Neuer Helper canonicalEmailKey normalisiert Mail- Adressen fĂŒr den Dedup-Vergleich (Plus-Tag wegstrippen, lowercase). billing+pentest@x.de und billing@x.de werden als dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail und im sync-Pfad gegen config.defaultForwardEmail.
    • 71.3 INFO: Neuer requireIdParam(req, res, paramName)-Helper fĂ€ngt nicht-numerische Route-Parameter und liefert 400 statt 500. Alle acht parseInt-Stellen in stressfreiEmail.controller.ts umgestellt (auch ĂŒber das gemeldete Finding hinaus).
    • 71.4 INFO: setAdditionalForwards rollt den DB-Stand bei Provider-Sync-Fehler zurĂŒck, damit DB und Plesk nicht auseinanderlaufen. Vorheriger additionalForwardingEmails-Wert wird vor dem Update gemerkt und bei Fail wieder eingespielt.
    • Smoke-Tests bestĂ€tigen: 11 reservierte TLDs abgelehnt, 4 echte TLDs (de, com, co.uk, museum) durchgewinkt, Plus-Tag- Strip funktioniert (auch mit Multi-Plus + Casing).
  • 🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen

    • Im „Adresse hinzufĂŒgen"-Modal erscheint der „Weitere Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync lĂ€uft direkt nach createEmail mit der vollen Liste.
    • Sub-Modal generalisiert: value/onChange-Pattern (controlled). Mit email-Prop → API-Persist pro Änderung (Edit). Ohne email → lokaler State (Create). Counter-Badge am Button zeigt die Anzahl Adressen.
  • 🆕 Stressfrei-Wechseln-Adressen: zusĂ€tzliche Weiterleitungsziele

    • Neues Feld StressfreiEmail.additionalForwardingEmails (Text/ JSON-Array), Migration 20260608100000_stressfrei_email_additional_forwards mit IF NOT EXISTS.
    • syncForwardingForEmail zieht die zusĂ€tzlichen Adressen mit in die Plesk-set:-Liste ein (case-insensitive Dedup gegen customer.email und config.defaultForwardEmail).
    • Neuer Endpoint PUT /api/stressfrei-emails/:id/additional-forwards mit Body { emails: string[] } – ersetzt die Liste und syncht direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per Regex, Audit-Log.
    • Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button (Edit-Modus + providerStatus === exists) öffnet ein Sub-Modal mit Liste + Add/Remove. Jede Änderung geht sofort live.
  • 🐞 Modal-Felder ließen sich nicht editieren (ZĂ€hler/Bankkarte/Ausweis/ZĂ€hlerstand)

    • Vier identische Vorkommen desselben Anti-Patterns wie beim AddressModal-Fix von 2026-06-03: setFormData(getInitialFormData()) im Render-Body, getriggert durch formData.X !== prop.X. Jeder Tastendruck setzte den State zurĂŒck.
    • Fix in allen vier Modals (MeterModal, BankCardModal, IdentityDocumentModal, MeterReadingModal): nach useEffect mit [<entity>?.id]-Dependency umgezogen.
  • 🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB

    • Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos haben 4000-6000 px Kante (24 MP), das vergrĂ¶ĂŸert die JPEG-Datei auch ohne Re-Encode auf 5-10 MB pro Bild.
    • Fix: Bilder beim HinzufĂŒgen auf max. 2400 px lange Kante runterskaliert (~290 DPI auf A4 = DruckqualitĂ€t) und als JPEG mit Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied) persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler PDF-Embed laufen alle auf dem skalierten Bild.
    • Erwartete GrĂ¶ĂŸe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).
  • 🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"

    • Tabs-Komponente um optionalen Prop tabHrefBuilder(tabId) erweitert. Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines ExternalLink-Icon. CustomerDetail ĂŒbergibt den Builder mit ?tab=<id>-Query-Param, der eh schon vom URL-Sync genutzt wird.
    • target="_blank" + rel="noopener noreferrer" + Klick-stopPropagation, damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.
  • 🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform

    • Zwei neue optionale Felder Contract.customerNumberAtSalesPlatform + contractNumberAtSalesPlatform, Migration 20260603150000_contract_sales_platform_numbers mit IF NOT EXISTS.
    • Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip (CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
    • Bonus: das fehlende contractNumberAtProvider im Renewal-Copy und Audit-Label-Mapping ist gleich mit drin – wurde bisher nicht in VVL-FolgevertrĂ€ge kopiert.
  • 🆕 Email-Links öffnen im neuen Tab

    • In EmailDetail nach der DOMPurify-Sanitize jedes <a>-Element auf target="_blank" + rel="noopener noreferrer" gesetzt. Letzteres verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen in einem useMemo, das nur bei Wechsel der Email neu rechnet.
  • 🐞 assertSafePdf: jspdf-PDFs mit JPEGs fĂ€lschlich als „JavaScript" blockiert

    • Stage-Bug: User lĂ€dt Ausweis als „JPGs → PDF" hoch → 415 mit Meldung „PDF enthĂ€lt JavaScript-Action". Backend hat den jspdf- Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream enthielten zufĂ€llig die Byte-Folge „/JavaScript" → Pattern-Match.
    • Fix: vor dem Pattern-Scan stream..endstream-Blöcke aus dem PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb von Streams (in PDF-Object-Dictionaries) – BinĂ€r-Streams enthalten Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
    • Smoke-Test: jspdf-Style-PDF mit /JavaScript-Bytes im Stream durchgewinkt, echte /OpenAction /S /JavaScript weiterhin blockiert, clean PDF weiterhin OK.
  • 🐞 AddressModal: Straße-Feld ließ sich nicht editieren

    • setFormData wurde unbedingt im Render-Body aufgerufen, wenn formData.street !== address.street. Jeder Tastendruck löste neu aus → Reset auf DB-Wert → Cursor sprang zurĂŒck → keine Eingabe möglich.
    • Fix: in useEffect mit [address?.id]-Dependency umgezogen. Re-Init nur beim Wechsel/Open, nicht bei jedem Render.
  • 🐞 Upload-Limit: Multer 10 MB → 25 MB (Ausweis-Scans, JPGs→PDF)

    • Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit (auch mit Original-Bytes-Optimierung aus 431792e). Limits in upload.routes.ts, gdpr.routes.ts, contract.routes.ts auf 25 MB hochgezogen. pdfTemplate.routes.ts war eh schon bei 20 MB.
    • Frontend: FileUpload-Hinweis „max. 10 MB" → „max. 25 MB".
  • 🆕 SIM-Karten: Checkbox „eSIM" zwischen „Hauptkarte" und „Multisim"

    • Hardware-Plastikkarte vs. eSIM-Profil ist eine eigene Eigenschaft – eSIM kann sowohl Hauptkarte als auch Multisim sein, also zusĂ€tzlich statt entweder/oder.
    • Schema: SimCard.isEsim Boolean @default(false), Migration 20260603100000_sim_card_esim mit IF NOT EXISTS.
    • Backend: alle vier SimCard-Schreibpfade in contract.service.ts (Create + Update + Follow-Up + Renewal) plus FE-Type-Definition.
    • UI: dritte Checkbox in ContractForm zwischen Hauptkarte und Multisim. ContractDetail zeigt blauen eSIM-Badge neben Hauptkarte.
  • 🆕 JpgToPdfModal: PDF-GrĂ¶ĂŸe drastisch reduziert (Original-Bytes + Quality 0.95)

    • Stage-Bug: 2 Handy-JPGs Ă  2 MB → PDF >10 MB → Multer 413. Ursache: Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB auf (Quality 100 % heißt nicht „identisch zum Original", sondern „möglichst viele Bits pro Pixel" – ein schon JPEG-komprimiertes Smartphone-Foto wird so kĂŒnstlich 4-8× grĂ¶ĂŸer).
    • Fix 1: Wenn Rotation/Flip unverĂ€ndert (Standardfall), wird die Original-DataURL 1:1 in die PDF eingebettet – kein Canvas-Roundtrip, keine Quality-AufblĂ€hung. 2-MB-JPEG bleibt 2 MB. Funktioniert fĂŒr JPEG und PNG (Format-Detection per data:image/png-Prefix).
    • Fix 2: Bei Transformation: toDataURL('image/jpeg', 0.95) statt 1.0. Visuell identisch fĂŒr Foto-Inhalte (Adobe-Lightroom-Default), aber 50-70 % kleiner.
    • Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher 16-30 MB), 2 gedrehte ≈ 5-8 MB.
  • 🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type

    • Globaler Error-Handler in index.ts:461 matcht /sind erlaubt|nicht erlaubt/i und mappt auf 415. Meine 70.1- Message „
 WebP erlaubt" (ohne „sind") rutschte durch und landete bei 500 + Error-Log-Spam.
    • Fix: 1 Zeile in contract.routes.ts – 
 WebP-Dateien sind erlaubt macht den Regex glĂŒcklich. Andere Routes (upload.routes.ts, gdpr.routes.ts, pdfTemplate.routes.ts, factoryDefaults.routes.ts, appSetting.routes.ts) nutzen alle schon „sind erlaubt".
  • 🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter

    • contract.routes.ts Vertragsdokumente: Multer-fileFilter blockte image/gif und image/webp, obwohl validateUploadedFile beide Typen zulĂ€sst. Folge: GIF mit korrektem MIME image/gif → 415, GIF mit gespooftem MIME image/jpeg → akzeptiert (vom Magic-Byte als .gif erkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte Guard), aber inkonsistent.
    • Fix: Whitelist um image/gif + image/webp ergĂ€nzt → konsistent zum zentralen validateUploadedFile und zu upload.routes.ts.
  • 🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert

    • contract.routes.ts Vertragsdokumente-Upload hatte bisher nur den PDF-Inhalts-Scan (scanUploadedPdfIfPresent aus 68.1). JPG/PNG- Uploads waren ungeprĂŒft – kompensiert durch Download-Layer (fileDownload.controller.ts liefert nur bei Magic-Byte-Match inline aus, sonst attachment). Pentester selbst: "ohne Exploit- Pfad", aber inkonsistent zu upload.routes.ts.
    • Refactor: detectType + validateUploadedFile aus upload.routes.ts in neue Middleware middleware/uploadFileTypeValidator.ts ausgelagert (Single Source of Truth). Beide Routes nutzen jetzt denselben Helper.
    • contract.routes.ts: validateUploadedFile ersetzt das schlankere scanUploadedPdfIfPresent – jetzt greift Magic-Byte + canonical Rename + PDF-Scan fĂŒr Vertragsdokumente analog zu allen anderen Upload-Pfaden.
    • pdfUploadSafety.ts: scanUploadedPdfIfPresent entfernt (tot, da nur in contract.routes verwendet wurde). requireSafeUploadedPdf bleibt fĂŒr gdpr.routes Vollmacht + pdfTemplate.routes.
  • 🔒 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Inhalts-Validierung + Modal-Limit

    • 68.1 PDF-Active-Content-Filter: Magic-Byte-Check prĂŒfte bisher nur %PDF-. PDFs mit /JavaScript, /JS, /Launch (externes Programm), /EmbeddedFile, /RichMedia (Flash) wurden inline an den Viewer ausgeliefert – Browser-PDF-Viewer (Chrome/Firefox) ignorieren JS, Adobe Acrobat aber nicht.
    • Neuer Helper assertSafePdf(buf) in utils/sanitize.ts: String-Scan auf die fĂŒnf Action-Pattern (case-sensitive nach PDF 32000-1:2008 §7.3.5). Wirft ApiError(415, ...) bei Treffer.
    • Neue Middleware pdfUploadSafety.ts mit zwei Varianten:
      • requireSafeUploadedPdf – Datei MUSS PDF sein, sonst 415.
      • scanUploadedPdfIfPresent – durchwinkt JPG/PNG, scannt nur PDFs.
    • EingehĂ€ngt:
      • upload.routes.ts (Magic-Byte-Validator erweitert)
      • gdpr.routes.ts Vollmacht-Upload
      • pdfTemplate.routes.ts Template-Upload
      • contract.routes.ts Vertragsdokumente
      • cachedEmail.controller.ts Email-Anhang-Pfade (3 Stellen: saveAttachmentTo, saveAttachmentAsInvoice, saveAttachmentAsContractDocument)
    • Inline-Vorschau bleibt erhalten – das war die explizite Anforderung (Augen-Button öffnet PDF im neuen Tab). Pentester- Empfehlung „disposition=inline abschalten" wurde bewusst NICHT umgesetzt, weil sie das eigentliche Acrobat-Risiko nicht löst (PDF auf Disk + Doppelklick → Acrobat → JS lĂ€uft trotzdem).
    • Edge-Case-Test bestĂ€tigt: /JSXForm und /JavaScriptFooter werden NICHT als JavaScript-Action erkannt (word-boundary \b greift).
    • 68.2 Modal-Limit: JpgToPdfModal hatte kein Bild-/GrĂ¶ĂŸen-Limit. Jetzt MAX_IMAGES = 50 + MAX_IMAGE_BYTES = 25 MB pro Bild. UX-Schutz, kein Security-Bug (Self-DoS only).
  • 🆕 JPGs → PDF: Button ĂŒberall bei PDF-Upload

    • Neue Komponente JpgToPdfModal (lokal im Browser via jspdf, keine Backend-Round-Trip nötig). Mehrere Bilder hinzufĂŒgen per Klick, Drag&Drop oder Strg+V (Clipboard-Image), Reihenfolge per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung + Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite, A4 mit automatischer Hoch-/Querformat-Wahl je Bild.
    • FileUpload-Komponente (11 Stellen: Datenschutz-PDF, Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung, Handelsregister, KĂŒndigungsschreiben + -BestĂ€tigung + deren Optionen) bekommt automatisch einen sekundĂ€ren "JPGs → PDF"-Button, wenn accept PDF einschließt.
    • Direkt-Inputs ebenfalls erweitert: Vertragsdokumente (ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab), Rechnungen (InvoicesSection).
    • PdfTemplates bewusst ausgenommen – braucht echte AcroForm-PDFs mit Formularfeldern, Bild-PDFs wĂ€ren unbrauchbar.
  • 🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"

    • customerEmailLabel existierte im Backend (Schema + Update-Logik + Public-Endpoint), war im UI aber nicht erreichbar – das Label kam ausschließlich aus deriveLabelFromDomain. Jetzt neuer optionaler Input „Bezeichnung im UI" unter dem Domain-Block in Einstellungen → E-Mail-Provider. Leer = Auto-Ableitung, befĂŒllt = ĂŒberschreibt das Tab-Label und alle anderen Stellen, die customerEmailLabel nutzen (max 60 Zeichen).
  • 🆕 SIM-Karten: Feld „Kartennutzer"

    • Bei Firmen- und FamilienvertrĂ€gen weicht der Vertragsinhaber (Firma/Eltern) vom tatsĂ€chlichen Nutzer (Mitarbeiter/Kind) ab. Neuer optionaler SimCard.cardUser (String), Migration 20260601100000_sim_card_user mit IF NOT EXISTS.
    • ContractForm: eigene Zeile „Kartennutzer" oberhalb der technischen SIM-Felder mit erklĂ€rendem Placeholder.
    • ContractDetail: zeigt „Nutzer: <Name>" neben den Hauptkarte/Multisim-Badges (nur wenn gefĂŒllt).
  • 🆕 VorgĂ€ngervertrag-Modal: Kundennr./Vertragsnr. sichtbar machen

    • Die „Anbieter & Tarif"-Card im ContractDetailModal war nur sichtbar, wenn Provider oder Tarif gesetzt waren – Bei EntwĂŒrfen ohne Anbieter wurden so auch customerNumberAtProvider + contractNumberAtProvider versteckt, obwohl sie gepflegt sein können. Fix: Sichtbarkeitsbedingung um die beiden Felder erweitert.
  • 🆕 ContractEmails-Card: Quicklinks auch ohne Postfach

    • „Postfach öffnen" + „Stressfrei wechseln Adressen" waren nur im Normal-Zweig sichtbar. Jetzt in einer gemeinsamen cardTitle-Variable extrahiert und auch im „Kein-Mailbox"-State sichtbar.
  • 🆕 Vertrags-Forms: Mini-Links zu Stammdaten

    • In ContractEmailsSection neben „Postfach öffnen" jetzt zusĂ€tzlich „Stressfrei wechseln Adressen" → Kundenakte-Tab.
    • In ContractForm kleine ExternalLink-Icons neben Select-Labels: Lieferadresse/Rechnungsadresse → ?tab=addresses, Bankkarte → ?tab=bankcards, Ausweis → ?tab=documents, Anbieter + Tarif → /settings/providers, Vertriebsplattform → /settings/platforms.
    • Select-Komponente nimmt jetzt ReactNode als label (statt nur string), rĂŒckwĂ€rtskompatibel.
    • In ContractDetail (Strom/Gas Card-Header) zusĂ€tzlich permanenter „ZĂ€hler verwalten"-Link in neuem Tab.
  • 🆕 Vertragshistorie: Vertragsnummern als Link

    • Erkennt Vertragsnummern (PREFIX-RANDOM) in title und description via Regex, löst sie gegen previousContract + followUpContract des aktuellen Vertrags auf und rendert sie als Link in neuem Tab. Nicht aufgelöste Nummern bleiben als Text.
  • 🆕 UI-Bug-Fix: „Wurde sondergekĂŒndigt?"-Checkbox-Label

    • Label-Klasse war flex → Block-Layout ĂŒber volle col-span-2-Breite. Klick rechts neben dem Text triggerte ungewollt die Checkbox. Fix: inline-flex.
  • 🆕 „ZĂ€hler verwalten"-Link im FolgezĂ€hler-Form

    • In der SuccessorMeterForm (Vertragsansicht) im Header rechts neben „FolgezĂ€hler hinzufĂŒgen (ZĂ€hlerwechsel)" ein Link in neuem Tab zur ZĂ€hler-Verwaltung des Kunden, damit man bei „Kein passender ZĂ€hler verfĂŒgbar" direkt rĂŒberspringen kann.
  • 🆕 FolgezĂ€hler-Forms: Checkbox „Alten ZĂ€hler deaktivieren"

    • Beide FolgezĂ€hler-Forms (Kundenakte MeterModal + Vertragsansicht SuccessorMeterForm) bekommen eine Checkbox, die standardmĂ€ĂŸig angehakt ist. Beim Speichern wird der VorgĂ€nger automatisch auf isActive=false gesetzt – ein-klick-fĂ€higer ZĂ€hlerwechsel.
    • Backend: createMeter mit successorOf und addSuccessorMeter akzeptieren deactivatePredecessor (Default true).
  • 🆕 Kundenakte → ZĂ€hler: VertrĂ€ge-Aufklappliste + Filter

    • Pro ZĂ€hler ein „VertrĂ€ge (N)"-Aufklapp, listet alle VertrĂ€ge auf, die diesen ZĂ€hler nutzen – als HauptzĂ€hler (energyDetails.meterId) oder ĂŒber FolgezĂ€hler-Kette (ContractMeter). Dedupliziert. Jeder Eintrag ist Link in neuen Tab mit Vertragsnummer, Anbieter, Status-Badge.
    • Neue Checkbox „ZĂ€hler ohne VertrĂ€ge anzeigen" neben „Inaktive anzeigen". Filtert die Liste auf Orphans (ZĂ€hler ohne Vertragszuordnung):
      Inaktive ohne VertrÀge Ergebnis
      ☐ ☐ nur aktive ZÀhler (Default)
      ☑ ☐ alle ZĂ€hler
      ☐ ☑ aktive ZĂ€hler ohne Vertrag
      ☑ ☑ alle ZĂ€hler ohne Vertrag
  • 🆕 FolgezĂ€hler-Button auch bei Single-Meter-VertrĂ€gen

    • FolgevertrĂ€ge ohne ContractMeter-Eintrag (alte Daten oder pure Single-Meter-VertrĂ€ge) bekamen den Button nie zu Gesicht. Fix: Button wird jetzt aus dem if/else gerendert, sobald entweder ein Single-Meter oder ContractMeter- EintrĂ€ge vorhanden sind. Im Backend wird der bisherige energyDetails.meterId bei Single-Meter-VertrĂ€gen automatisch als ContractMeter (position 0, removedAt = Wechseldatum) backfillt, damit der alte ZĂ€hler in der Historie bleibt.
  • 🆕 Multi-Meter-Verbrauch auf Vertragslaufzeit clampen

    • Bei VertrĂ€gen, die VorgĂ€nger einer Folgevertrags-Kette sind, hĂ€ngen ĂŒber ContractMeter auch FolgezĂ€hler dran, die nach Vertragsende installiert wurden. Die Berechnung nahm cm.installedAt..cm.removedAt 1:1 ohne Clamp gegen contract.startDate/endDate – damit flossen ZĂ€hlerstĂ€nde aus der Folgevertrags-Phase in den Verbrauch des Vorvertrags ein. Fix: meterStart = max(installedAt, contractStart), meterEnd = min(removedAt, contractEnd), ZĂ€hler komplett außerhalb der Laufzeit werden ĂŒbersprungen.
  • 🆕 Vertragsansicht: Standort + Inaktiv-Badge beim ZĂ€hler

    • Strom/Gas-Card zeigt jetzt neben der ZĂ€hlernummer ein rotes „Inaktiv"-Badge (falls deaktiviert) und darunter eine kleine Zeile „Standort: 
" (falls gepflegt).
  • đŸ›Ąïž Pentest 43.5 (INFO) + 43.6 (MEDIUM): History-XSS + blocked:-Marker

    • 43.6: 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).
    • 43.5: stripHtml ersetzt javascript: → blocked: – sinnvoll bei URL-Feldern, hĂ€sslich in Tarif-/Preis-Namen. Neuer stripForDisplay-Wrapper entfernt den Marker zusĂ€tzlich in den Display-Feldern.
  • đŸ›Ąïž Pentest 42.5 (MEDIUM): priceFirst12Months XSS

    • Drei Preisfelder sind im Schema String? (freitextliche Tarifangaben). sanitizeContract strippte sie auf dem Read-Pfad nicht – Alt-Daten mit XSS-Payloads kamen 1:1 raus. Fix: priceFirst12Months, priceFrom13Months, priceAfter24Months in CONTRACT_DISPLAY_STRING_FIELDS aufgenommen.
  • đŸ›Ąïž Anzeige-Defense: HTML in display-relevanten Strings strippen

    • 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. Damit sehen Pentester-Payloads (<script>, <img onerror>) in der Vertragsliste nicht mehr als hĂ€sslicher Klartext aus, sondern verschwinden komplett.
  • 🆕 Vorvertrag-Verbrauch als SchĂ€tzwert im Folgevertrag

    • ContractForm (Strom/Gas): Wenn ein previousContractId gesetzt ist, wird der Vorvertrag samt Readings nachgeladen und der Verbrauch clientseitig ĂŒber calculateMultiMeterConsumption / calculateConsumption berechnet. Unter dem Jahresverbrauch-Feld erscheint Vorvertrag: 1.698 kWh (hochgerechnet) [Übernehmen] mit Ein-Klick-Button, der den Wert ins Feld kopiert. Bei Gas erscheint der Hinweis sowohl unter „Jahresverbrauch (mÂł)" (mit mÂł-Wert) als auch unter „Jahresverbrauch (kWh)".
    • ContractDetail (Strom/Gas): Wenn annualConsumption leer ist und ein berechenbarer Vorvertrag existiert, wird die Jahresverbrauch-Zelle stattdessen mit ~1.698 kWh in blau angezeigt, darunter klein „geschĂ€tzt aus Vorvertrag (hochgerechnet)". Verschwindet automatisch, sobald der Wert im Vertrag eingetragen ist.
    • Funktioniert nur bei VertrĂ€gen mit explizitem previousContract (Folgevertrag-Kette). Ohne Vorvertrag oder ohne genĂŒgend ZĂ€hlerstĂ€nde kommt kein Hinweis.
  • 🆕 Endstand alter ZĂ€hler fließt in Verbrauchsberechnung ein

    • Bisher wurde der Wert „Letzter Stand alter ZĂ€hler" zwar als ContractMeter.finalReading gespeichert, aber nirgends gelesen – weder in der UI noch in calculateMultiMeterConsumption.
    • Neuer Helper recordPredecessorFinalReading(meterId, switchAt, value) in customer.service.ts: legt am Wechseldatum einen regulĂ€ren MeterReading-Eintrag fĂŒr den VorgĂ€nger an (Notes: „Endstand bei ZĂ€hlerwechsel"). Idempotent: existiert am Wechseltag schon ein Reading, wird nichts geschrieben. Validierung (monoton-steigend) wird vorab durchgefĂŒhrt – Konflikt fĂŒhrt zu sprechender 400-Fehlermeldung, ohne halb-geschriebene ZustĂ€nde zu hinterlassen.
    • Wird aus beiden Pfaden aufgerufen: addSuccessorMeter im contract.controller (Vertragsansicht → „FolgezĂ€hler hinzufĂŒgen") und createMeter mit successorOf im customer.service (Kundenakte → „Als FolgezĂ€hler deklarieren").
    • Folge: Der Endstand erscheint jetzt in der ZĂ€hlerstĂ€nde-Liste des VorgĂ€nger-ZĂ€hlers und fließt ĂŒber calculateMultiMeterConsumption automatisch in den Verbrauch (Zeitraum bis removedAt ist inklusive).
    • UI-Hinweise im FolgezĂ€hler-Form (Vertragsansicht + MeterModal) erklĂ€ren den neuen Effekt.
  • 🆕 FolgezĂ€hler-Button auch bei Single-Meter-VertrĂ€gen

    • Bisher nur sichtbar im Multi-Meter-Zweig (contractMeters.length > 0) – FolgevertrĂ€ge ohne ContractMeter-Eintrag konnten so keinen FolgezĂ€hler bekommen.
    • Fix: Button wird jetzt aus dem if/else-Block gerendert, sobald entweder ein Single-Meter (energyDetails.meter) oder ContractMeter-EintrĂ€ge vorhanden sind.
    • Im Backend addSuccessorMeter: bei Single-Meter-VertrĂ€gen wird der bisherige energyDetails.meterId automatisch als ContractMeter (position 0, removedAt = Wechseldatum) backfillt, damit der alte ZĂ€hler nicht aus der Vertragshistorie verschwindet.
  • 🆕 FolgezĂ€hler-Deklaration in der Kundenakte (Auto-Propagation)

    • Backend: Neues Feld Meter.predecessorMeterId (Self-Relation, ON DELETE SET NULL). Migration 20260530140000_meter_predecessor mit IF NOT EXISTS. createMeter akzeptiert optional successorOf: { predecessorMeterId, installedAt?, finalReadingPrevious? }. Wenn gesetzt: VorgĂ€nger wird validiert (gleicher Kunde + gleicher Typ), und fĂŒr alle VertrĂ€ge, die den VorgĂ€nger als aktuellen ZĂ€hler nutzen, wird der ContractMeter-Eintrag analog zu addSuccessorMeter propagiert (vorhandener ContractMeter wird removedAt + finalReading gesetzt; neuer ContractMeter wird mit nĂ€chster Position + installedAt angelegt; energyDetails.meterId auf den NeuzĂ€hler aktualisiert). Idempotent gegen Doppel-Klick.
    • MeterModal (Kundenakte → ZĂ€hler): Bei Neuanlage neue Checkbox „Diesen ZĂ€hler als FolgezĂ€hler deklarieren". Wenn aktiv: Dropdown VorgĂ€nger-ZĂ€hler (alle ZĂ€hler des Kunden, inkl. inaktive – mit Suffix), Wechseldatum (default heute), Endstand alter ZĂ€hler (optional). Bei VorgĂ€nger-Auswahl werden Typ, Tarifmodell und Adresse vom VorgĂ€nger ĂŒbernommen und disabled. Info-Banner: „Alle VertrĂ€ge mit dem alten ZĂ€hler werden automatisch umgestellt".
    • Audit-Log: „ZĂ€hler angelegt als FolgezĂ€hler von X fĂŒr Kunde #N".
  • 🆕 Vertragsansicht: Standort + Inaktiv-Badge beim ZĂ€hler

    • In den Strom/Gas-Details neben der ZĂ€hlernummer zusĂ€tzlich ein rotes „Inaktiv"-Badge und eine Zeile mit Standort, falls hinterlegt.
  • 🆕 ZĂ€hler → Lieferadresse-Pflichtfeld + Vertragsfilter

    • Backend: Neues Feld Meter.addressId (optional FK auf Address, ON DELETE SET NULL). Migration 20260530100000_meter_address mit IF NOT EXISTS. Service erzwingt beim Create: Lieferadresse muss vorhanden, zum Kunden gehören und Typ DELIVERY_RESIDENCE haben.
    • MeterModal (Kundenakte → ZĂ€hler): Pflicht-Dropdown "Lieferadresse" ĂŒber allen Feldern. Button "ZĂ€hler hinzufĂŒgen" ist disabled, wenn keine Lieferadresse existiert – mit gelbem Hinweis-Banner. BestandszĂ€hler ohne Adresse zeigen im MetersTab "Lieferadresse: nicht zugeordnet – bitte ĂŒber Bearbeiten nachpflegen" in gelb.
    • ContractForm (Strom/Gas): ZĂ€hler-Dropdown filtert jetzt auf die im Vertrag gewĂ€hlte Lieferadresse. Deaktivierte ZĂ€hler bleiben sichtbar (Label-Suffix (deaktiviert)); wenn sie ausgewĂ€hlt werden, kommt ein Toast: „Deaktivierter ZĂ€hler ausgewĂ€hlt. Ist das gewollt? Handelt es sich um einen Altvertrag?". Platzhalter wechselt zwischen "Erst Lieferadresse wĂ€hlen
" / "Kein ZĂ€hler fĂŒr diese Adresse vorhanden" / "ZĂ€hler wĂ€hlen
".
    • Audit-Log loggt Adress-Änderung am ZĂ€hler als Feld "Lieferadresse".
  • 🆕 Backup-Operations-Log + EBUSY-Fix beim Restore

    • Zwei neue Log-Panels auf der DB-Backup-Seite: links "Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder Eintrag zeigt ✓/✗-Status, Summary, Timestamp und User. Klick öffnet ein Modal mit dem vollstĂ€ndigen Verlauf (alle console.log/error/warn/info-Zeilen werden wĂ€hrend der Operation in einen Puffer mitgefangen).
    • Persistiert in neuer Tabelle BackupLog (Migration 20260519100000_backup_log mit IF NOT EXISTS). Limit 1 MB pro fullLog, Auto-Refresh alle 5s.
    • Endpoints (settings:update): GET /api/settings/backup-logs?operation=CREATE|RESTORE, GET /api/settings/backup-logs/:id.
    • Bonus: Das neue Log hat sofort einen alten Bug aufgedeckt – EBUSY: rmdir '/app/uploads' beim Restore. Ursache: das Backup-Service rief deleteDirectory(UPLOADS_DIR) mit dem finalen rmdirSync, aber /app/uploads ist ein Bind-Mount, den Linux nicht aushĂ€ngen lĂ€sst. Fix: neuer Helper emptyDirectory() löscht nur die Inhalte, das Verzeichnis selbst bleibt stehen.
    • Live-verifiziert: 4867 DatensĂ€tze + 1 Datei in 13.2s wiederhergestellt, Log-Modal zeigt den vollstĂ€ndigen Verlauf.
  • đŸ›Ąïž Pentest 2026-05-28 LOW 34.5: Backend-URL-Validierung fĂŒr AppSettings

    • Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend. API-Endpoint akzeptierte sonst /relative/path, javascript:, ftp://, http://192.168.1.1 etc. → Open-Redirect / SSRF-Vektor in den an Kunden verschickten Portal-Mails.
    • Neuer validateSettingValue(key, value) in appSetting.service.ts mit per-Key-Logik: portalLoginUrl → nur http(s), absoluter Host, isBlockedSsrfHost-Check, Trailing-Slash-Strip. Schwellenwerte (deadline*/documentExpiry*) → positive Integer. Bool-Settings → strict true/false. monitoringAlertEmail → RFC-5322-light.
    • Controller (updateSetting + updateSettings) ruft Validator nach der HTML-Strip-Sanitisierung; bei Fehler 400 mit aussagekrĂ€ftiger Message. Bulk-PUT validiert ALLE Werte bevor irgendwas gespeichert wird (kein halb-committed-State bei einem ungĂŒltigen Eintrag).
    • Live-verifiziert auf dev:
      • /evil/path → 400 "muss absolute http(s)-URL sein"
      • javascript:alert(1) → 400 (durch stripHtml zu blocked: → Validator: unzulĂ€ssiges Schema)
      • ftp://evil.com / data:text/html → 400
      • http://169.254.169.254 → 400 (Cloud-Metadata immer geblockt)
      • http://192.168.1.1 → 200 (on-prem-Default; mit SSRF_BLOCK_PRIVATE_IPS=true → 400)
      • https://crm.example.de/ → DB: https://crm.example.de (Slash gestrippt)
      • https://crm.example.de//abc/ → DB: https://crm.example.de//abc (nur trailing slash; doppelte slashes mittendrin bleiben)
  • 🐛 Bugfix: Portal-Passwörter in VertrĂ€gen wurden mutiliert

    • Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive sanitizeContractBody-Funktion lief auch ĂŒber portalPassword. Passwörter mit HTML-Pattern (Pass<TAG>word! → Password!) oder URI-Schema-Prefix (data:secret → blocked:secret) wurden irreparabel zerstört.
    • Fix: PASSTHROUGH_KEYS = {'portalPassword', 'password'} – beim Walk werden String-Werte unter diesen Keys NICHT durch stripHtml geschickt. PW wird sowieso encrypt()-verschlĂŒsselt persistiert und niemals als HTML ausgegeben → kein XSS-Risk.
    • Live-verifiziert: PW MyP@ss<word>123!&data:foo → byte-genau im GET-Decrypt-Endpoint zurĂŒck. providerName: <script>
 → weiter auf EvilProvider gestrippt. portalUsername: u<test> → weiter auf u gestrippt.
  • đŸ›Ąïž Pentest 2026-05-24 Pen-31-Befunde (2× MEDIUM)

    • 31.1 Stored XSS in Vertragsfeldern: providerName, tariffName, priceFirst12Months, priceFrom13Months, priceAfter24Months nahmen rohe HTML/Script-Payloads an und lieferten sie 1:1 zurĂŒck. Fix: rekursiver sanitizeContractBody() (Walk-and-Strip) im contract.controller wird auf req.body von POST + PUT angewandt. Nutzt das bestehende stripHtml() aus utils/sanitize, inkl. URI-Schema-Block + Entity-Decode. VertrĂ€ge enthalten keine legitimen HTML-Felder (Editor-HTML lebt in AppSettings), daher Strip ohne Risiko. Audit-Vergleich nutzt jetzt die sanitisierte Version, sonst Audit ↔ DB-Drift.
    • 31.2 IDOR auf GET /customers/:id/stressfrei-emails (und 4 weiteren Routes mit requireCustomerAccess): das Middleware short-circuitete auf customers:read – aber Portal-User haben diese Perm im JWT (fĂŒr eigene Daten). Damit kam Portal-Kunde 1 an IMAP-Konten/Adressen/Bank-Cards/Documents/Meters von Kunde 3. Fix in middleware/auth.ts:requireCustomerAccess: erst isCustomerPortal-Check (eigene + vertretene IDs), DANN erst Perm-Check fĂŒr Mitarbeiter. Damit sind alle 6 Routes mit einem Middleware-Patch dicht. Defense-in-Depth: in stressfreiEmail.controller.getEmailsByCustomer zusĂ€tzlich canAccessCustomer-Call analog zum POST-Handler.
    • Infos (keine Code-Änderung):
      • type:"STROM" ist deprecated – richtige Enum ist ELECTRICITY.
      • HSTS auf Staging fehlt: HSTS macht der nginx-Reverse-Proxy, Backend setzt's bewusst nicht (Doppel-Header-Vermeidung). Auf Staging muss der Proxy-Op das HSTS-Header-Add aktivieren.
      • Portal-Login-Rate-Limit 5 vs 10: Env-Drift, identische Codebase.
    • Live-verifiziert auf dev:
      • Portal-User 1 vs Customer 3: alle 6 Routes 403 (/customers/3, .../addresses, .../bank-cards, .../documents, .../meters, .../stressfrei-emails).
      • XSS-Payloads <script>, <svg/onload>, <img onerror>, javascript:, &#60;script&#62; in 5 Vertragsfeldern → DB-Werte bereinigt (EvilProvider, blocked:alert(4) 35€ etc.).
  • 🆕 Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Doppel-Input

    • Info-Icon neben Kundennamen öffnet ein Modal mit den wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort, GrĂŒndungsdatum, primĂ€re Adresse, Telefon, Mobil, E-Mail, Portal-E-Mail, Steuer-/Handelsregister-Nr.). Jedes Feld hat einen Copy-Button (bestehende CopyableValue-Komponente). Neue Komponente: CustomerInfoModal.tsx. Lazy-Fetch via customerApi.getById, staleTime 30s.
    • Cent/Euro-Doppel-Input fĂŒr Arbeitspreise (Strom + Gas): Neben dem €/kWh-Feld jetzt ein zweites Feld ct/kWh. Bidirektional verkoppelt – Tippen in € aktualisiert ct (×100), Tippen in ct aktualisiert € (Ă·100). Im Backend wird unverĂ€ndert nur der Euro-Wert persistiert; Cent ist reine UI-Hilfe. Float-Rausch- Schutz (Math.round × 1e6) verhindert "0.25 → 25.0000000000
". Greift fĂŒr unitPrice und (bei DUAL-ZĂ€hlern) unitPriceNt.
  • 🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)

    • Bisher gab es ein einzelnes bonus-Feld auf EnergyContractDetails. Jetzt zwei Felder instantBonus (Sofort) und newCustomerBonus (Neukunden), die zusammen den Gesamtbonus ergeben.
    • Migration 20260524100000_split_energy_bonus: ADD COLUMN IF NOT EXISTS instantBonus, ... newCustomerBonus, UPDATE kopiert bestehendes bonus → instantBonus (Annahme: BestandsvertrĂ€ge hatten primĂ€r Sofort-Bonus), dann DROP COLUMN IF EXISTS bonus. Idempotent.
    • Form (ContractForm.tsx): zwei Input-Felder „Sofort-Bonus (€)"
      • „Neukunden-Bonus (€)" statt einem.
    • Detail-Ansicht: zeigt beide Felder einzeln und einen aggregierten „Gesamtbonus" (fett) darunter.
    • Kostenvorschau: listet Sofort + Neukunden einzeln auf (jeweils grĂŒn), dann „Gesamtbonus" (mittel grĂŒn, fett), danach Effektive Jahreskosten.
    • Cost-Calc (energyCalculations.ts): calculateCosts() nimmt jetzt beide Bonus-Werte; CostCalculation liefert instantBonus, newCustomerBonus + totalBonus.
    • PDF-Template-Variablen: drei neue Placeholder energyDetails.instantBonus, .newCustomerBonus, .totalBonus (alter .bonus-Placeholder entfernt).
    • Audit-Log unterscheidet jetzt beide Felder.
    • Live-verifiziert auf dev: PUT mit beiden Feldern → DB hat instantBonus=75, newCustomerBonus=125, GET liefert beide Werte zurĂŒck.
  • đŸ›Ąïž Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)

    • 30.13 MIME-Extension-XSS (MEDIUM): GET /api/files/download lieferte hochgeladene Dateien via res.sendFile aus. Da multer nur den client-gemeldeten MIME prĂŒft, konnte eine als application/pdf deklarierte .html-Datei auf Disk landen – Express bestimmt beim Senden den Content-Type aus der Extension (.html → text/html) und der Browser hĂ€tte gerendert. Stored XSS fĂŒr eingeloggte EmpfĂ€nger. Fix: Content-Disposition: attachment; filename=<safe> + bestehendes X-Content-Type- Options: nosniff. Browser lĂ€dt jetzt herunter statt zu rendern, selbst wenn der Type stimmt. Filename wird auf [A-Za-z0-9._-] gesĂ€ubert.
    • 30.14 SSRF Private-IP-Block opt-in (INFO): Neuer Env-Flag SSRF_BLOCK_PRIVATE_IPS=true erweitert die SSRF-Block-Liste auf 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1, fc00::/7 + IPv4-mapped Varianten + Hostnamen "localhost"/ "ip6-localhost". Default off, damit On-Prem-Installationen (Plesk/Dovecot auf 127.0.0.1) nicht brechen. Cloud-Deployments setzen den Flag.
    • Live-verifiziert auf dev:
      • Upload + Download: Header zeigt Content-Disposition: attachment; filename="
" + X-Content-Type-Options: nosniff
      • Default-ssrfGuard: 127.0.0.1 / 10.x / 192.168.x / localhost → false (durchgelassen fĂŒr on-prem); 169.254.169.254 → true (Cloud- Metadata weiter geblockt)
      • Mit SSRF_BLOCK_PRIVATE_IPS=true: alle privaten Ranges → true; 8.8.8.8 (legit public) → false
  • đŸ›Ąïž Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)

    • 28.1 Restarbeit: DANGEROUS_URI_SCHEMES jetzt vollstĂ€ndig – blob:, about:, ws:, wss:, ldap:, dict: ergĂ€nzt. Bewusst NICHT geblockt: http(s):, mailto:, tel: (legitime URLs in Notizfeldern).
    • 29.1 Cyrillic-Homoglyph: jаvascript: mit kyrillischem а (U+0430) lief vorher durch. HOMOGLYPH_TO_ASCII-Map mit den 13 ĂŒblichen Spoofing-Buchstaben (а→a, Д→e, ĐŸâ†’o, 
) wird VOR dem Scheme-Strip angewendet. Legitime Buchstaben außerhalb der Map bleiben unangetastet.
    • 29.2 Percent-Encoding: java%73cript: umging die Regex. percentDecode() lĂ€uft jetzt iterativ bis zu 5 Runden (fĂ€ngt auch java%2573cript: ab, das zuerst zu java%73cript: und dann zu javascript: wird).
    • 29.3 Zero-Width-Joiner: j​av​ascript: mit U+200B/200C/200D etc. wird durch ZERO_WIDTH_CHARS-Regex entfernt, bevor irgendein Match lĂ€uft.
    • 28.3 Partial – PDF-Validierung tiefer: nicht nur Magic-Bytes, sondern auch (1) %%EOF-Marker in den letzten 1 KB und (2) Pattern-Scan der ersten 4 KB auf #!/, <script, <?php, <%, MZ (PE-Header). Dadurch wird %PDF-1.4\n#!/bin/bash jetzt sauber abgelehnt. Voll-PDF-Parsing wĂ€re ĂŒberzogen – die simplen Spoofs sind weg.
    • 29.4 Email-Format-Validator: neuer isValidEmail() in sanitize.ts lehnt Whitespace/Newlines/Tabs (= SMTP-Header- Injection-Vektor wie test@x.de\nBcc:attacker@
) sowie Format- MĂŒll wie notanemail ab. Verdrahtet in createCustomer/updateCustomer/updatePortalSettings/ createUser/updateUser. Liefert 400.
    • 29.5 GET /api/providers/email 500 → 404: parseInt("email") war NaN, Prisma-Query crashte. Controller validiert jetzt Number.isFinite(id) && id ≄ 1 und liefert 404 ("Anbieter nicht gefunden"). Numerische IDs funktionieren weiter.
    • Live-verifiziert auf dev:
      • blob:/about:/ws:/wss:/ldap:/dict: alle → blocked:
      • jаvascript:alert(1) (cyr) → blocked:alert(1)
      • java%73cript:alert(1) → blocked:alert(1)
      • java%2573cript:alert(1) → blocked:alert(1)
      • ZWJ-Variante → blocked:alert(1)
      • %PDF-1.4\n#!/bin/bash → 400 + Datei gelöscht
      • PDF ohne %%EOF → 400
      • Echtes PDF mit %%EOF → 200
      • email: "notanemail" → 400
      • email: "test@x.de\nBcc:..." → 400
      • GET /providers/email → 404 (war 500)
      • GET /providers/<valid_id> → 200
  • đŸ›Ąïž Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)

    • 28.1 URI-Schema unvollstĂ€ndig: DANGEROUS_URI_SCHEMES-Regex erweitert um file: und ftp:. ftp://evil.com/x.js und file:///etc/passwd in companyName werden jetzt zu blocked://... bzw. blocked:///... neutralisiert.
    • 28.2 HTML-Entity-Decoding-Bypass: stripHtml lief vorher direkt ĂŒber den Roh-String, sodass &#106;avascript:, &#x3C;script&#x3E; und &lt;script&gt; an der Regex vorbeischlĂŒpften. Neuer decodeHtmlEntities() lĂ€uft VOR dem Strip und dekodiert numerische (&#NN;/&#xHH;) + gĂ€ngige Named-Entities (lt/gt/quot/apos/amp). Danach greift die normale Tag- und URI-SĂ€uberung wieder.
    • 28.3 Vollmacht-Upload Magic-Byte-Check: multer prĂŒfte nur den client-gemeldeten MIME-Type, eine HTML/PHP-Datei als application/pdf kam durch. Neuer Check liest die ersten 5 Bytes nach dem Upload und verlangt %PDF- – sonst wird die Datei gelöscht und 400 geliefert. Greift bevor irgendwas in der DB landet.
    • 28.4 Rate-Limit auf /api/public/consent: 30 Requests pro IP pro 15 Minuten. Brute-Force-sicher war der 128-Bit-UUID-Hash schon, aber ohne Limit konnte ein Angreifer das System per POST-Spam mit Audit-Logs und Mail-Versand belasten. Neuer publicConsentRateLimiter greift jetzt auf alle drei Sub-Routes (/:hash, /:hash/grant, /:hash/pdf).
    • Live-verifiziert auf dev:
      • ftp://evil.com/x.js → blocked://evil.com/x.js
      • file:///etc/passwd → blocked:///etc/passwd
      • &#106;avascript:alert(1) → blocked:alert(1)
      • &#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;OK → OK
      • &lt;script&gt;bad()&lt;/script&gt;Legit → Legit
      • HTML-Datei als PDF hochgeladen → 400 + Datei gelöscht
      • Echtes PDF (Magic-Bytes ok) → 200
      • 35× POST auf public-consent → Req 1–30 = 404, Req 31+ = 429
  • đŸ§č Pentest 2026-05-20 LOW/INFO-Sammelfix

    • 27.1 Path-Traversal-Strings in DB: cleanupConsents validierte documentPath zuvor nur per stripHtml, was ../../../etc/passwd durchließ (kein File-Read, aber dreckige Datenbasis). Neuer isValidDocumentPath-Check akzeptiert nur /?uploads/<safe>, alles andere wird auf NULL gesetzt.
    • Generischer cleanupDocumentPaths-Pass ĂŒber die fĂŒnf weiteren Tabellen mit documentPath (BankCard, IdentityDocument, Invoice, RepresentativeAuthorization als nullable; ContractDocument NOT NULL → wird nur berichtet, manuell entscheiden).
    • Orphaned User: Neuer Report-Step reportOrphanedUsers warnt beim Container-Start vor User ohne Rollenzuordnung (= im Permission-System unsichtbar). Löschen tut das Skript nicht (false-positive-Risiko bei legitimen Spezial-Usern).
    • Seed-PW-Policy: generateInitialPassword() nutzte Math.random() (vorhersagbar). Jetzt crypto.randomInt() fĂŒr Pick + Shuffle, 28 Zeichen aus 4 Klassen.
    • PUT /users/:id mit permissions / password: vorher silent-drop durch Whitelist + HTTP 200. Jetzt explizit HTTP 400 mit Hinweis auf den dedizierten /password-Endpoint bzw. die Role-Steuerung. Gleicher Pattern wie PUT /portal fĂŒr password.
    • /api/health ohne Auth: BEWUSST so – Container-Healthcheck + Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur {status,timestamp} – keine Version, kein DB-Status, kein Hostname → kein Info-Leak. Kommentar im Code dokumentiert die Entscheidung.
    • Live-verifiziert auf dev:
      • CustomerConsent.documentPath=../../../etc/passwd → NULL
      • PUT /users mit permissions → 400 mit klarer Message
      • PUT /users mit password → 400 mit Hinweis auf /password
      • Orphan-User angelegt → vom Cleanup-Lauf gemeldet
      • crypto.randomInt-Pfad rauscht durch ohne Fehler
  • đŸ›Ąïž Pentest 2026-05-20 MEDIUM+LOW: Consent + URI-Sanitization

    • 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 noch status aus Body, source server-seitig hardcoded auf 'portal', documentPath/version bleiben NULL (werden vom dedizierten Authorization-Upload-Endpoint server-seitig gesetzt).
    • Whitelist fĂŒr CustomerConsent.source ergĂ€nzt (portal | public-link | telefon | papier | email | crm-backend). grantAuthorization (Admin) erzwingt sie ebenfalls; notes lĂ€uft jetzt durch stripHtml.
    • LOW javascript:-URI: stripHtml() filtert jetzt zusĂ€tzlich javascript:, data:, vbscript: – ersetzt durch blocked:, damit <a href={companyName}> nichts feuert. VertrĂ€glich mit legitimem Text, der nicht "javascript:" enthĂ€lt.
    • Cleanup-Skript erweitert um cleanupConsents(): Whitelist-Reset fĂŒr source, stripHtml fĂŒr version/documentPath – idempotent, lĂ€uft beim Container-Start automatisch.
    • Live-verifiziert auf dev:
      • PUT mit {status:"GRANTED",source:"ADMIN_OVERRIDE", version:"<script>",documentPath:"../../etc/passwd"} → DB hat source=portal, documentPath=NULL, version=NULL.
      • companyName javascript:alert(1) → blocked:alert(1).
      • companyName <a href=data:text/html,<script>...>Click</a> → Click.
      • Cleanup auf dirty DB: source ADMIN_OVERRIDE → unknown, documentPath/version gesĂ€ubert.
  • 🚹 Pentest 2026-05-20 KRITISCH: Backup-Restore ohne Confirm-Body

    • POST /api/settings/backup/:name/restore startete bei leerem Body sofort den destruktiven Restore. Im Unterschied zu /factory-reset fehlte der Magic-String-Confirm-Check. Risiko: versehentlicher Re-Fire (Doppelklick, Browser-Replay, eingeloggter Admin auf bösartiger Drittseite) ĂŒberschrieb stillschweigend die komplette DB.
    • Fix: gleicher Defensive-Pattern wie factoryReset – Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten, sonst 400. Frontend-Client schickt den String beim Klick im BestĂ€tigungs- Dialog automatisch (kein UX-Change fĂŒr den User).
    • Live-verifiziert auf dev:
      • leerer Body → 400 "BestĂ€tigung fehlt"
      • {"confirm":"ja"} → 400 (wrong)
      • {"confirm":"RESTORE-BESTAETIGT"} → 200, Restore lief
  • đŸ›Ąïž XSS-Sanitization fĂŒr Plain-Text-AppSettings (Pentest MEDIUM)

    • companyName (und weitere Plain-Text-Keys wie defaultEmailDomain, monitoringAlertEmail, Schwellenwerte) konnten via PUT /api/settings/:key mit XSS-Payloads befĂŒllt werden – das war nur Admin-triggerbar, aber E-Mail-Templates/PDF-Generatoren hĂ€tten den Wert ungescaped rendern können.
    • Fix: neuer sanitizeSettingValue(key, value) in appSetting.service.ts strippt HTML außer fĂŒr die expliziten HTML-Editor-Keys (imprintHtml, privacyPolicyHtml, authorizationTemplateHtml, websitePrivacyPolicyHtml). Greift in updateSetting (Einzel) und updateSettings (Bulk).
    • Cleanup-Skript erweitert: bestehende AppSettings mit HTML in Plain-Text-Keys werden beim Container-Start gestrippt (idempotent).
    • Live-verifiziert auf dev: `OpenCRM <script>alert(2)</script>` via PUT → DB-Wert: `"OpenCRM"`. imprintHtml mit <h1><p> → unverĂ€ndert.
  • 🐛 Rollen-Perms-Sync beim Container-Start (Follow-up DSGVO-Fix)

    • Bestehende Installationen liefen weiter mit veraltetem Permission-Set fĂŒr die DSGVO-Rolle (audit:read u.a. fehlten), weil prisma db seed per docker-entrypoint nur auf leeren DBs lĂ€uft. Folge: Settings.tsx-Fix vom VorgĂ€nger-Commit half nicht, weil das JWT die fehlende Perm gar nicht enthielt.
    • Neuer Step im Entrypoint: npx tsx prisma/sync-roles.ts lĂ€uft bei jedem Start. Idempotent, fasst nur Permission- und Role-Tabellen an (keine User/Customers/Contracts), fĂŒhrt syncRolePermissions fĂŒr Admin, Developer, DSGVO, Mitarbeiter, Mitarbeiter (Nur-Lesen), Kunde aus.
    • Live-verifiziert: audit:read aus DSGVO-Rolle gelöscht, Script laufen lassen → "+1 Permission an Rolle #27", wieder vollstĂ€ndig.
  • 🐛 DSGVO-Rolle: MenĂŒpunkte in den Einstellungen unsichtbar

    • Symptom: User mit ausschließlich DSGVO-Rolle sah keinerlei Karten unter Einstellungen → System (DSGVO-Dashboard, DatenschutzerklĂ€rung, Vollmacht-Vorlage, Impressum, Website-Datenschutz, E-Mail-Versandlog, Audit-Protokoll).
    • Ursache: Der gesamte System-Block in frontend/src/pages/Settings.tsx war in hasPermission('settings:update') eingewickelt. DSGVO hat aber nur audit:* und gdpr:* Perms – kein settings:update.
    • Fix: Outer-Check erweitert auf settings:update || audit:read || gdpr:admin. Jede Karte hat weiterhin ihren eigenen feingranularen Check; fĂŒr DSGVO-User erscheinen nur die DSGVO-/Audit-Karten.
    • Backend-API mit reiner DSGVO-Rolle (kein Admin) live durchgetestet: /api/gdpr/dashboard, /api/audit-logs, /api/email-logs, /api/gdpr/privacy-policy, /api/gdpr/authorization-template, /api/gdpr/imprint, /api/gdpr/website-privacy-policy, /api/gdpr/consents/overview, /api/gdpr/deletions → alle 200. Backend war nicht das Problem.
  • đŸ›Ąïž Login-Rate-Limit jetzt pro (IP + Email)-Tupel

    • Vorher reine IP-basierte Sperre, was zwei SchwĂ€chen hatte: a) Familie hinter NAT: Max vertippt sich → Nina kommt nicht rein b) Angreifer wechselt Proxy → wieder 10 freie Versuche pro Account, dieselbe IP-only-Sperre umgangen.
    • Eine reine Email-Sperre wurde verworfen wegen Account-Lockout- DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP- Problem.
    • Lösung: Bucket-Key ist ${ip}|${email-lowercase}. Damit:
      • Max von IP-A 10x vergeigt → (IP-A, max) gesperrt
      • Nina von IP-A → eigenes Bucket (IP-A, nina), unbetroffen
      • Admin von IP-A mit richtigem PW → erfolgreicher Login
      • Max von IP-B → eigenes Bucket (IP-B, max), darf wieder
    • Implementation: loginRateLimiter.keyGenerator = ${ip}|${email} in middleware/rateLimit.ts; nur ein Limiter, kein zusĂ€tzlicher Email-only.
    • Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt beides mit, Audit-Log resourceId = ${ip}|${email}.
    • Live-verifiziert (4 Schritte): 11x falsch max → 429, Nina/Admin von gleicher IP → durch, max bleibt gesperrt, Reset → max wieder 401.
  • 🚹 PUT /customers/:id/portal mit password im Body → 400

    • Endpoint nahm password silent entgegen, ignorierte es, gab aber HTTP 200 zurĂŒck → Client glaubte fĂ€lschlich, das Passwort sei gesetzt. Fix: explizite Body-Validierung – password, portalPassword, portalPasswordHash, portalPasswordEncrypted sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten POST /portal/password-Endpoint.
  • 🚹 Pentest Runde 17 – JWT-TTL + Pentest-Marker-Detection

    • 21.1 Access-Token 7 Tage: Bug-Quelle waren die .env-Files, die noch die alte Konvention vor der Refresh-Token-Trennung hatten (JWT_EXPIRES_IN=7d). docker-compose.yml und .env.example standen schon richtig auf 15m als Default. Alle .env-Files (Root, backend/, docker/.env.example, backend/.env.example) jetzt auf JWT_EXPIRES_IN=15m mit explizitem JWT_REFRESH_EXPIRES_IN=7d. Auf prod kann der Container mit dem neuen Default neu hochgezogen werden.
    • 17.5 Alte Pentest-Daten in DB: das Cleanup-Script lĂ€uft schon bei jedem Container-Start, strippt HTML aus Customer/ User-Strings und entfernt nicht-whitelisted AppSettings. Es erkannte aber keine Test-Records ohne HTML (z.B. Customer mit email: hacker@evil.de). Erweiterung:
      • Neue Marker-Pattern-Liste: ^hacker@, ^attacker@, ^pentest@, @evil\., <script\b, onerror=, javascript:, SQL-Injection + Path-Traversal-Pattern.
      • Bewusst eng: nur EMail-Adressen die mit dem Marker BEGINNEN, damit legitime Kunden mit "hacker" o.Ă€. im Nachnamen (z.B. stefanhacker@gmx.de) NICHT als Pentest-Marker durchgehen.
      • Default-Verhalten: nur warnen + AufzĂ€hlen. Mit CLEANUP_PURGE_PENTEST=true-ENV werden die markierten Customer/User-Records gelöscht.
      • Live-verifiziert: stefanhacker@gmx.de (echt) → durch; hacker@evil.de (Test) → erkannt + Warnung; mit Purge-Env → gelöscht.
    • 18.4 Klartext-Portal-PW-Abruf: BEWUSST DRIN GELASSEN auf Wunsch (Admin-UI-Komfort). Klartext bleibt fĂŒr Admin via GET /customers/:id/portal/password abrufbar; ist mit customers:update-Permission gated und mit Audit-Log (READ → PortalPassword) auditiert.
  • 🚹 Pentest Runde 15 – KRITISCH: portalPasswordHash in PUT/POST-Response

    • 20.3 KRITISCH: PUT /customers/:id gab den vollen bcrypt-Hash ($2a$12$
) zurĂŒck, weil updateCustomer Service-Output ohne sanitize-Aufruf direkt durchreichte. GET-Endpoints waren dicht, die Update-Response nicht. 20.4 HOCH gleicher Klasse: portalPasswordResetToken, consentHash, portalPasswordMustChange, portalTokenInvalidatedAt leakten ebenfalls ĂŒber PUT/POST.
    • Fix:
      • updateCustomer + createCustomer rufen jetzt sanitizeCustomer/sanitizeCustomerStrict auf den Service- Output (je nach customers:update-Permission).
      • updateContract + createContract + createFollowUp + createRenewal analog mit sanitizeContract/Strict (Portal-Hint via req.user.isCustomerPortal).
      • portalPasswordMustChange und portalTokenInvalidatedAt zusĂ€tzlich von PORTAL_HIDDEN_CUSTOMER_FIELDS zu SENSITIVE_CUSTOMER_FIELDS hochgezogen – damit greift der Schutz auch bei der normalen sanitizeCustomer-Variante (Admin-Sicht). Auch Pentester-Empfehlung in HOCH-Klasse.
    • Live-verifiziert:
      • Admin PUT /customers/3 {firstName:
} → 0 Leaks bei portalPasswordHash/ResetToken/Expires/MustChange/consentHash/ TokenInvalidatedAt; portalPasswordEncrypted bleibt fĂŒr Admin sichtbar (UI-Workflow)
      • Portal-User GET /customers/3 → 0 Leaks auch bei portalPasswordEncrypted/notes
      • POST /customers (create) ebenfalls dicht
  • 🛟 Admin-Rescue-Script (PW-Reset direkt in DB + Rate-Limit-Reset)

    • Use Case: Admin sperrt sich aus (z.B. admin@admin.com ist keine echte E-Mail → Passwort-vergessen-Flow kann keine Mail senden) oder Brute-Force-Lockout will sich nicht auflösen.
    • Node-Script backend/prisma/reset-admin-password.ts: findet User per Email, hasht PW mit bcrypt cost 12, schreibt direkt in user.password, setzt tokenInvalidatedAt = now() (kickt alle bestehenden Sessions), löscht ggf. anhĂ€ngende Reset-Tokens. Mit oder ohne PW-Argument (random 28-char wenn leer), KomplexitĂ€ts-Check 25 Zeichen fĂŒr eigene PWs.
    • Bash-Wrapper scripts/admin-rescue.sh:
      • password <email> [pw] – PW-Reset im laufenden Container via docker exec opencrm-app npx tsx prisma/reset-admin-

      • unlock – Container-Restart leert den In-Memory-Rate-Limit- Store komplett (alle IPs frei)
      • all <email> [pw] – beides
    • Live-verifiziert: random-Modus liefert 28-char PW, schwaches eigenes PW wird mit allen Defizit-Punkten abgelehnt, langes eigenes PW akzeptiert, unbekannter User → klarer Fehler, bash-Syntax-Check ok.
  • 🔐 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)

    • 12 Zeichen sind heute der untere akzeptable Rand. NIST/OWASP/BSI empfehlen 14-25+ Zeichen. Mitarbeiter/Admin nutzen Passwort-Manager → LĂ€nge kostet nichts, Sicherheitsgewinn ist real.
    • Schwellwerte: STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12 (Endkunden tippen das auch auf dem Handy ein).
    • Backend-Pfade:
      • createUser + register + setUserPassword → 25 Zeichen
      • setPortalPassword + changeInitialPortalPassword → bleibt 12
      • confirmPasswordReset: Server bestimmt Audience anhand des Tokens (getPasswordResetAudience) → User-Token = 25, Customer- Token = 12. Damit kann ein Angreifer nicht durch Body-Hint auf den schwĂ€cheren Schwellwert ausweichen.
    • Seed-Admin: Default-Passwort jetzt 28-char Zufallspasswort (alle 4 Klassen garantiert), via SEED_ADMIN_PASSWORD-ENV ĂŒberschreibbar – aber nur wenn ≄ 25 Zeichen, sonst ignoriert mit Log-Warnung.
    • Frontend:
      • UserList: Hinweis-Text "Mind. 25 Zeichen". Update + Passwort gleichzeitig → Frontend macht jetzt zwei Calls (PUT + neuer POST /users/:id/password) statt Passwort durch Update-Body durchzuschmuggeln.
      • PasswordResetConfirm: Hinweis "Mind. 12 Zeichen (Mitarbeiter: 25)", Server entscheidet endgĂŒltig.
      • userApi.setPassword(id, password) neu in api.ts.
    • Live-verifiziert:
      • POST /users/6/password "Hallo123!Test" (12 chars) → 400 "mindestens 25 Zeichen"
      • POST /users/6/password "MeinExtremLangesPW2026!Test" → 200, Login mit dem neuen PW → success
      • POST /customers/3/portal/password "Hallo123!Test" (12) → 200
      • POST /users {
,password:"Hallo123!Test"} → 400 (25-char-Floor)
    • NĂ€chster grĂ¶ĂŸerer Sprung wĂ€re MFA fĂŒr Mitarbeiter-Login (TOTP via Authenticator). Eigenes Thema, separate Aufgabe.
  • 🚹 Pentest Runde 12 – Folge-Fixes: XSS-Reste, User-PW-Endpoint, JS-Error-Leak, Seed-PW

    • M2-Reste (XSS-Strings noch in DB): neues idempotentes Script prisma/cleanup-xss-and-mass-assignment.ts lĂ€uft beim Container-Start. Strippt HTML aus Customer/User-String-Feldern; entfernt AppSettings, deren Key nicht in ALLOWED_SETTING_KEYS steht. Mehrfacher Aufruf Ă€ndert nichts.
    • User-Update akzeptierte password-Feld: stillschweigend ohne dedizierten Audit-Eintrag. Jetzt: password aus USER_UPDATABLE_FIELDS raus (CREATE behĂ€lt es weiterhin); neuer Endpoint POST /api/users/:id/password mit eigenem Audit-Log "Passwort 
 durch Admin gesetzt", KomplexitĂ€ts-Check inklusive.
    • JS-Runtime-Fehler leakten weiter: ORM-Leak-Patterns erweitert um TypeError, ReferenceError, SyntaxError, RangeError, "Cannot read propert(y|ies) of (undefined|null)", "is not a function", "is not defined". Greift im globalen res.json()-Wrapper.
    • POST /contracts substring-Crash: defensiv – type fehlt → 400 mit klarer Meldung; generateContractNumber() fĂ€ngt auch leere/null type ab (Fallback "CON").
    • Seed-Admin-Passwort "admin" verletzte Policy: jetzt 16-Zeichen-Zufallspasswort beim Seed (mit allen 4 Klassen garantiert) oder via SEED_ADMIN_PASSWORD-ENV ĂŒberschreibbar; BCRYPT-Cost auf 12 (war 10); Passwort wird einmalig beim Seed in stdout ausgegeben mit Warnung.
    • AppSettings-Whitelist ergĂ€nzt: companyName, defaultEmailDomain (kommen aus seed.ts, waren in der ersten Whitelist vergessen).
    • Live-verifiziert: POST /contracts {} → klare 400 statt JS-Crash; PUT /users/6 {password:...} ignoriert (Login mit altem PW geht weiter); POST /users/6/password mit kurz → 400; Cleanup-Script: 1 Customer bereinigt, 2 unbekannte AppSettings entfernt (hackerSetting, debugMode), Re-Lauf → 0 Änderungen.
  • 🚹 Pentest Runde 11 – Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip

    • C2 KRITISCH – Factory Reset ohne BestĂ€tigung: Eingeloggter Admin konnte mit leerem oder beliebigem Body ({confirm:true}, {confirm:false}, {}) die komplette DB plĂ€tten (3× in einer Session passiert). Fix: server-side wird jetzt confirm: "FACTORY-RESET-BESTAETIGT" als String erzwungen, sonst HTTP 400. Frontend-API schickt den exakten String mit.
    • M1 – Settings Mass Assignment: PUT /api/settings und PUT /api/settings/:key nahmen JEDEN Key-Value-Pair an (superAdminEmail, debugMode, allowedOrigins etc. landeten direkt in der DB). Fix: Whitelist ALLOWED_SETTING_KEYS in appSetting.service.ts, Helper isAllowedSettingKey(). Unbekannte Keys → HTTP 400 mit expliziter AufzĂ€hlung der ungĂŒltigen Keys.
    • M3 – Prisma-Error-Leak in jeder Response: Statt 30+ Controller einzeln zu fixen: globaler res.json()- Wrapper unter /api, der error/details-Strings durch einen Pattern-Filter schickt. Bekannte ORM-/Stack-Trace-Muster (Invalid \prisma., PrismaClient, Stack-Frames) werden zu "Operation fehlgeschlagen"ersetzt. Original-Text bleibt im Server-Log via[orm-leak-guard]`.
    • M2 – Stored XSS in Customer/User-Strings: <script>alert(1)</script> und Ă€hnliche Payloads landeten ungefiltert in der DB. Fix: neuer stripHtml()-Helper, von pickCustomerUpdate/Create und pickUserUpdate/Create auf allen String-Werten angewandt (Defense-in-Depth – React auto-escaped schon, aber PDF-Generator/E-Mail-Templates könnten exec-Vektoren sein).
    • Live-verifiziert (alle vier):
      • /factory-reset mit {}, {confirm:true}, {confirm:false} → HTTP 400, DB unangetastet
      • PUT /settings {superAdminEmail,debugMode,allowedOrigins} → 400 + Keys aufgezĂ€hlt; gĂŒltige Keys → 200
      • PUT /users/99999 → "Operation fehlgeschlagen" statt Prisma-Stack; Server-Log behĂ€lt Original
      • PUT /customers/3 {companyName:"<script>...</script>EvilCorp"} → gespeichert als "EvilCorp"; <img onerror> weg
  • 🚹 Pentest Runde 10 – Live-Vollmacht-Konsistenz + DTO-Leaks in embedded Objekten

    • MEDIUM – Stale Token nach Vollmacht-Widerruf: Selbst ein FRISCHER Portal-Login lieferte JWT mit representedCustomerIds: [7] und representedCustomers: [{Nina,
}], obwohl die Vollmacht widerrufen war. Live-Check beim Datenzugriff funktionierte (403), aber die UI zeigte dem Vertreter weiter, dass er Nina vertreten könne.
      • Fix: customerLogin und getCustomerPortalUser (= /me + Refresh-Pfad) filtern representingFor jetzt zusĂ€tzlich ĂŒber getAuthorizedCustomerIds() – nur Beziehungen mit isGranted: true landen im Token und in /me.
      • Verifiziert: Customer 1 (vertritt 2,3 aber alle Vollmachten widerrufen) → JWT.representedCustomerIds = [], /me ebenfalls.
    • MEDIUM – DTO-Leak in embedded Objekten: GET /customers/:id lieferte zwar Customer-Top-Level sanitisiert, aber contracts[] darin enthielt weiterhin commission, notes, portalPasswordEncrypted, nextReviewDate. Analog notes auf embedded customer in /contracts/:id.
      • Fix: sanitizeCustomer(Strict) ruft jetzt sanitizeContract(Strict) fĂŒr jedes Element in contracts[] auf. notes zu PORTAL_HIDDEN_CUSTOMER_FIELDS ergĂ€nzt (interne CRM-Vermerke).
      • Verifiziert: Portal-User sieht in customers/1.contracts[*] keine commission/notes/PW-Encrypted/nextReviewDate mehr; Admin sieht sie weiterhin (Workflow-Bedarf); portalPasswordEncrypted ist generell entfernt (Klartext nur via /contracts/:id/password mit Audit-Log).
    • LOW – /tasks?customerId=X 200 statt 403 fĂŒr fremde IDs: Konsistenz-Issue: nach Vollmacht-Widerruf gab der Endpoint leeres Array statt einen klaren 403-Fehler. Jetzt: wenn der Portal-User explizit nach einer customerId filtert, die er nicht (mehr) vertreten darf → 403 mit "Kein Zugriff auf diese Kundendaten". Verifiziert.
  • 🚹 Pentest Runde 7 (Anschlussrunde) – Information-Disclosure + Input-Validation

    • MEDIUM – Interne Felder in Portal-Responses:
      • sanitizeCustomerStrict strippt jetzt zusĂ€tzlich portalTokenInvalidatedAt, portalLastLogin, portalPasswordMustChange, lastBirthdayGreetingYear, privacyPolicyPath, businessRegistrationPath, commercialRegisterPath.
      • Neue sanitizeContract / sanitizeContractStrict / sanitizeContracts(Strict): entfernt portalPasswordEncrypted (immer; ist nur ĂŒber den dedizierten /password-Endpoint mit Audit-Log abrufbar) und fĂŒr Portal- User zusĂ€tzlich commission, notes, nextReviewDate.
      • getContract + getContracts rufen jetzt die passende Sanitize-Variante je nach req.user.isCustomerPortal auf; Mitarbeiter sehen weiterhin commission/notes (Admin-Workflow), nur portalPasswordEncrypted ist generell entfernt (Klartext nur ĂŒber dedicated Endpoint).
      • Live-verifiziert: Portal sieht 0 Leaks, Admin sieht commission/notes weiterhin.
    • LOW – Integer-Truncation bei IDs: parseInt('6abc') → 6 hat alle Endpoints durchgewunken. Neuer middleware in index.ts: jedes URL-Pfad-Segment unter /api, das mit Ziffer beginnt aber nicht aus reinen Ziffern besteht, wird mit HTTP 400 abgelehnt. Heuristik trifft alle /resource/<id>(\D+)-Patterns ohne dass jeder einzelne Sub-Router angefasst werden muss.
      • Live-verifiziert: /customers/6abc → 400 mit klarer Meldung, /customers/3 weiterhin 200, /contracts/1abc/history → 400, normaler Pfade /audit-logs/customer/3 → 200.
    • INFO – Login-Rate-Limit „nach 6 nicht aktiv": Code-Stand limit: 10 fĂŒr loginRateLimiter, lokal verifiziert: 11. Versuch = 429. Pentester sah vermutlich noch alten Build oder eine andere Lokation (PW-Reset hat limit: 5). Kein Code-Change.
  • 🛠 Rate-Limit-Sperren: Admin-UI zum Freigeben

    • Bei einer Pentest-Runde hat der Tester sich selbst durch zu viele Login-Versuche ausgesperrt → ohne Container-Restart kein Weg zurĂŒck. Jetzt: Admin sieht die Sperren und kann sie einzeln aufheben.
    • Datenquelle fĂŒr die Liste: SecurityEvent-Tabelle filtert nach type = RATE_LIMIT_HIT im 15-Min-Fenster (= Login-Window), gruppiert nach IP. Pro Eintrag: IP, zuletzt versuchte E-Mail, Limiter-Typ (Login / Passwort-Reset), Hit-Anzahl, Zeit seit letztem Hit.
    • Reset: ruft loginRateLimiter.resetKey(ip) und passwordResetRateLimiter.resetKey(ip) auf – exposiert von express-rate-limit v7. Idempotent, audited.
    • Backend:
      • GET /api/settings/rate-limits/active (settings:read)
      • POST /api/settings/rate-limits/reset (settings:update) mit Body { ipAddress }
      • neuer Controller rateLimitAdmin.controller.ts
    • Frontend: neue Seite /settings/rate-limits mit Tabelle + Freigeben-Button, 15s Auto-Refresh; Kachel in Settings-Übersicht (orange, neben „Sicherheits-Monitoring").
    • Live-verifiziert (4 Schritte): 11 falsche Logins von 127.0.0.1 → 11. → 429; Liste zeigt IP + Email + Hits; POST Reset → 200; nĂ€chster Login mit falschem PW → 401 statt 429 (Sperre weg); Audit-Log enthĂ€lt Eintrag.
  • 🚹 Pentest Runde 7 – Hit-List durchgegangen + kurzlebige Download-Tokens

    • Credential-Endpoints (Contracts password/internet/sip/simcard + Stressfrei mailbox/send/reset-password): ALLE bereits durch canAccessContract/canAccessStressfreiEmail gesichert – keine LĂŒcke gefunden.
    • GET /customers/:id/portal/password (Klartext-Portal-Passwort- Abruf): hatte KEINEN canAccessCustomer-Check. Fix: eingefĂŒgt. Defense in depth gegen versehentlich falsch vergebene customers:update-Permission.
    • Admin-Funktionen (factory-reset, developer/*, audit-logs/rehash, audit-logs/customer): alle durch admin-level Permissions (settings:update, developer:access, audit:admin, audit:read) geschĂŒtzt – Portal-User haben diese nicht.
    • Token-in-URL (NIEDRIG): Langlebige Access-JWTs landeten als ?token= in URLs fĂŒr PDF-iframe, Audit-Log-Export, PDF-Generate und Portal-Privacy-PDF → nginx-Access-Logs, Browser-History, Referer-Header.
      • Neuer Mechanismus: POST /api/auth/download-token liefert ein kurzlebiges JWT mit type: 'download' und exp: 60s.
      • Auth-Middleware akzeptiert type: 'download' AUSSCHLIESSLICH via ?token= Query, niemals als Bearer-Header. So kann ein in Logs geleaktes Download-Token nicht fĂŒr regulĂ€re API-Aufrufe missbraucht werden.
      • Frontend-Migration: 4 Stellen umgestellt (Audit-Log-Export, PDF-Template-Preview, PDF-Generate von ContractDetail + Modal, Portal-Privacy-PDF). fileUrl und getAttachmentUrl sind synchron und in vielen Components verstreut – Migration dieser bleibt als Folge-Aufgabe.
      • Live-verifiziert: Download-Token = 1773 Zeichen, type=download, exp-iat=60s, als Header → 401, als ?token= → 200.
  • 🚹 Pentest Runde 6 – Sammelfix + Strukturelles Audit (8 Findings + Audit-Sweep)

    • KRITISCH-01 GET /emails/:id/thread: kein Owner-Check → Portal-Kunde konnte alle Mail-Threads durchsuchen. Fix: canAccessCachedEmail im Controller.
    • KRITISCH-02 GET /customers/:customerId/representatives/search: kein canAccessCustomer auf den Pfad → DSGVO-GAU, Portal-Kunde konnte mit Buchstaben-Brute-Force die komplette Kunden-DB auslesen. Fix eingefĂŒgt.
    • HOCH-01 GET /birthdays/upcoming: kein Portal-Filter → Name, E-Mail, Telefon, Geburtsdatum aller Kunden lesbar. Fix: isCustomerPortal → 403.
    • HOCH-02 */contracts/:contractId/history: kein Owner-Check auf GET/POST/PUT/DELETE. Fix: canAccessContract in allen vier History-Handlern.
    • HOCH-03 Mailbox-Endpoints: mailbox-accounts, unread-count, contracts/:id/emails/folder-counts ohne Check. Fix: canAccessCustomer bzw. canAccessContract in allen drei.
    • HOCH-04 Live-Vollmacht-Check in Tasks: getTasks, createSupportTicket, createCustomerReply, getAllTasks, getTaskStats prĂŒften nur representedCustomerIds.includes(...) aus dem JWT – widerrufene Vollmachten hatten weiter Zugriff (JWT lebt bis zu 15min nach Widerruf). Neuer Helper getPortalAllowedCustomerIds() in accessControl.ts ruft hasAuthorization() live ab. Auch updateCustomerConsent (GDPR) auf diesen Pfad umgestellt.
    • MITTEL-01 confirmPasswordReset Klartext-Speicherung: Self-Service-Reset speicherte portalPasswordEncrypted = encrypt(pw). Klartext-Speicherung ist nur fĂŒr Admin-OTPs sinnvoll. Fix: Field auf null, zusĂ€tzlich portalPasswordMustChange = false.
    • MITTEL-02 Pagination-Total leakt globale Kunden-Anzahl: GET /customers gab total: 4271 auch wenn Portal-User nur 1 Kunde sah. Fix: customer.service.ts erweitert um allowedIds-Filter, der direkt in der DB-Query landet → die pagination zĂ€hlt nur ĂŒber erlaubte IDs.
    • Strukturelles Audit-Sweep (Sub-CRUD + Email-Operationen): Folgende Handler bekamen jetzt erstmals einen canAccess*- Check, defense in depth gegen falsch vergebene Rollen: markAsRead, toggleStar, assignToContract, unassignFromContract, deleteEmail, getTrashEmails, getTrashCount, restoreEmail, permanentDeleteEmail, getAttachmentTargets, saveAttachmentTo, saveEmailAsPdf, saveEmailAsInvoice, saveAttachmentAsInvoice, saveAttachmentAsContractDocument, createFollowUp, createRenewal, snoozeContract, removeContractMeter, updateAddress, deleteAddress, updateBankCard, deleteBankCard, updateDocument, deleteDocument, updateMeter, deleteMeter, addMeterReading, updateMeterReading, deleteMeterReading, markReadingTransferred, addRepresentative, removeRepresentative.
    • Live-verifiziert (Portal-User Customer 3 auf fremde IDs): customers/1/representatives/search → 403, birthdays/upcoming → 403 (Admin → 200), emails/21/thread → 403, customers/2/mailbox-accounts → 403, emails/unread-count?customerId=2 → 403, contracts/8/{history,folder-counts,follow-up,renewal,snooze} → 403, eigene customers/3 → 200, pagination.total fĂŒr Portal = 1 (statt 3), Customer 1 mit widerrufener Vollmacht → 0 fremde VertrĂ€ge.
  • 🚹 Pentest Runde 5 – KRITISCH: change-initial-portal-password ohne Pflicht-Check

    • Realer Angriff: Jeder Portal-User konnte jederzeit mit seinem eingeloggten Token POST /api/auth/change-initial-portal- password aufrufen und das eigene Passwort ohne Kenntnis des alten ersetzen. Der OTP-Flow-Endpoint hatte den Check portalPasswordMustChange === true nicht.
    • Konsequenz: Bei XSS oder kurzlebigem Token-Diebstahl konnte ein Angreifer das Passwort dauerhaft ĂŒbernehmen.
    • Fix: Eine Zeile in auth.controller.ts – prisma.customer.findUnique auf portalPasswordMustChange, bei false → 403 "Nicht erlaubt".
    • Live-verifiziert: ohne Flag → 403; mit Flag (nach send-credentials) → 200, danach Flag automatisch zurĂŒck auf false → erneuter Aufruf → 403.
  • Pentest Runde 5 – NIEDRIG: consentHash + Public-Grant-Response

    • consentHash wurde ĂŒber GET /api/customers/:id zurĂŒckgegeben. Der Hash ist Pseudo-Credential fĂŒr den öffentlichen Consent-Link (wer ihn hat, sieht Customer-Name + Kundennummer ohne Auth und kann Einwilligungen erteilen). Fix: in SENSITIVE_CUSTOMER_FIELDS aufgenommen. Wer ihn legitim braucht, holt ihn ĂŒber /gdpr/customer/:id/consent-status (eigener Check).
    • POST /api/public/consent/:hash/grant gab den vollen CustomerConsent[]-Array inkl. IP-Adressen und createdBy (Kunden-Name) zurĂŒck. Fix: Response auf { granted: <count> } reduziert. Frontend nutzt eh nur success-Flag.
    • Live-verifiziert: consentHash: null in customer-Response, consentHash weiterhin in /gdpr/.../consent-status, Grant-Response liefert nur {granted: 4} ohne Extra-Keys.
  • 🚹 Pentest Runde 4 – HOCH: Cockpit-IDOR (Portal-User sah ALLE Kunden)

    • Realer Angriff: Portal-User Max bekam mit seinem Token GET /api/contracts/cockpit → komplette Vertragsliste ALLER Kunden (Customer-Namen, Vertragsnummern, StatĆ«s).
    • Root Cause: contractCockpitService.getCockpitData() filterte nicht nach Customer, weil das Cockpit ursprĂŒnglich nur fĂŒr Admins gedacht war. Die contracts:read-Permission haben aber auch Portal-User → Endpoint war erreichbar.
    • Fix: Service-Signatur erweitert auf getCockpitData({ customerIds? }). Wenn customerIds gesetzt sind, werden Haupt-Vertrags-Query, Consent-Maps, Ausweis- Warnungen und gemeldete ZĂ€hlerstĂ€nde allesamt auf diese IDs eingeschrĂ€nkt. Controller bestimmt customerIds analog zu getContracts: bei isCustomerPortal → eigene + vertretene Kunden (nur mit Vollmacht); sonst undefined (= alle).
    • Live-verifiziert: Admin sieht 17 VertrĂ€ge (3 Kunden); Portal-User Customer 1 sieht 12 (nur seine); Portal-User Customer 3 sieht 3 (nur seine); 0 Leaks.
  • 🚹 Pentest Runde 3 – drei Findings gefixt

    • KRITISCH – POST /api/developer/setup ohne Auth (Privilege Escalation): Endpoint war komplett ohne Authentifizierung erreichbar und konnte der Admin-Rolle die developer:access- Permission verleihen → kompletter DB-Zugriff ĂŒber /developer/*. Fix: Endpoint ersatzlos gelöscht. Manuelles Setzen geht weiterhin ĂŒber prisma/add-developer-permission.ts (CLI). Live-verifiziert: POST /api/developer/setup → HTTP 404.
    • HOCH – Customer-Login DoS auf Prod (fehlende Migration): portalPasswordMustChange war im Code, aber prod-DB kannte die Spalte nicht → Prisma warf bei jedem Kunden-Login. Root Cause: in dieser Session wurde prisma db push benutzt (kein Migration- File). Fix: handgenerierte Migration 20260516173552_portal_password_must_change/migration.sql (via prisma migrate diff + migrate resolve --applied). Verifiziert durch shadow-DB-Reset + migrate deploy: Spalte landet korrekt in einer frischen DB. entrypoint.sh fĂŒhrt migrate deploy beim Container-Start bereits aus → Prod-Restart applied jetzt automatisch.
    • MITTEL – Prisma-Internals-Leak im Login-Error-Body: Bei unerwarteten Fehlern (Schema-Bruch, DB-Down) wurde error.message direkt zurĂŒckgegeben → Tabellen-/Spaltennamen leakten. Fix: Whitelist-Filter safeLoginError() in auth.controller.ts: nur bekannte Messages ('UngĂŒltige Anmeldedaten', 'E-Mail und Passwort erforderlich') werden durchgereicht, alles andere wird zu generischem 'Anmeldung fehlgeschlagen' und das Original landet im Server-Log. Greift fĂŒr Mitarbeiter- UND Portal- Login. Live-verifiziert: Spalte testweise gedropped → Client sieht generisch, Server-Log enthĂ€lt Original.
  • 🔐 Einmalpasswort-Flow fĂŒr Portal-Credentials

    • Intention: Wenn wir Zugangsdaten per E-Mail an den Kunden schicken, kennen wir das Passwort als Admin – das ist solange OK, bis er sich einmal eingeloggt hat. Danach soll er gezwungen sein, sich ein eigenes zu vergeben, und das per-Mail-Passwort ist tot.
    • Datenmodell: neues Feld portalPasswordMustChange: Boolean @default(false) am Customer.
    • Flow:
      1. Admin klickt Zugangsdaten versenden → Flag wird gesetzt, Mail-Template weist explizit auf „Einmalpasswort" hin.
      2. Kunde loggt sich mit dem OTP ein → Backend gibt mustChangePassword: true im Login-Response zurĂŒck UND konsumiert das OTP sofort: setzt portalPasswordHash = null und portalPasswordEncrypted = null. Ein zweiter Login mit demselben Passwort schlĂ€gt fehl (401).
      3. Frontend (ProtectedRoute) sieht mustChangePassword=true und leitet auf /change-initial-password um – egal welche Route der Kunde aufrufen will, er kommt nicht weiter.
      4. Auf der Seite gibt er ein neues, komplexes Passwort vor (Live-Hint mit ✓/○, dieselben Regeln wie Backend).
      5. POST /api/auth/change-initial-portal-password speichert neuen Hash, löscht das Encrypted-Feld (Admin kann das eigene Passwort des Kunden nicht mehr im Klartext lesen), setzt portalTokenInvalidatedAt = now() und portalPasswordMustChange = false.
      6. Frontend loggt aus, leitet zu /login?changed=1, Erfolgs-Banner: „Passwort wurde geĂ€ndert. Bitte mit dem neuen Passwort anmelden."
    • Edge case: Tab geschlossen ohne Setzen → Kunde ist ausgesperrt (OTP weg, eigenes Passwort nicht gesetzt). Lösung aus seiner Sicht: Passwort-vergessen-Funktion oder Admin versendet neue Zugangsdaten.
    • Edge case: Admin macht zwischendurch nochmal manuelles „Setzen" → mustChange wird automatisch wieder false. So kann ein versehentlich versendetes OTP problemlos durch ein direkt-gesetztes Passwort ersetzt werden.
    • Live-verifiziert (10 Schritte): Setzen → Send → Flag in DB=true → Login mit OTP gibt mustChange=true zurĂŒck + Hash in DB ist null → Re-Login mit OTP → 401 → Change-Endpoint schwach → 400 → komplex → 200 → Login mit neuem PW → mustChange=false + tokenInvalidatedAt gesetzt.
  • 🔐 Passwort-KomplexitĂ€t + Portal-Credentials-UX

    • Problem: Bisher reichten 6 Zeichen fĂŒr gesetzte Passwörter (Portal-Login, User-Reset, Registrierung, User-Anlage). Das hat der Pentest bemĂ€ngelt, und es entsprach auch nicht dem, was wir selbst von Endkunden erwarten wĂŒrden.
    • Lösung:
      • validatePasswordComplexity() in passwordGenerator.ts: mind. 12 Zeichen + Großbuchstaben + Kleinbuchstaben + Ziffer
        • Sonderzeichen, mit detaillierter Fehlerliste auf deutsch.
      • Erzwungen in 5 Endpoints: setPortalPassword, confirmPasswordReset, register, createUser, updateUser.
    • Neue UX im Kunden-Portal-Block (CustomerDetail):
      • Generate-Button: erzeugt 16-Zeichen-Zufallspasswort, das garantiert allen KomplexitĂ€tsregeln entspricht, und fĂŒllt das Eingabefeld direkt aus.
      • Send-Credentials-Button: schickt Login-URL + Username + Klartext-Passwort an die Kunden-E-Mail. Funktioniert nur, wenn "Portal aktiviert" tatsĂ€chlich aktiviert ist.
      • Live-KomplexitĂ€ts-Hint beim Tippen: ✓/○-Liste zeigt sofort, welche Regeln noch fehlen.
      • alert()-Boxen durch Toast-Notifications ersetzt.
    • Live-verifiziert: schwaches Passwort hallo123 → HTTP 400 mit Fehlerliste, komplexes Passwort Hallo123!Test → HTTP 200, Generator-Endpoint liefert 16-Zeichen-Passwort, Send-Credentials versendet Mail nur bei portalEnabled=true.
  • 🌐 Real-IP hinter Nginx-Proxy-Manager

    • Problem: Rate-Limiter und Security-Monitor haben statt der echten Client-IP nur die NPM-IP (172.0.2.12) geloggt. Damit wĂ€ren alle Threshold-basierten Blockings nutzlos – ein Brute- Force von 100 verschiedenen Clients wĂ€re fĂŒr uns 1 Quelle.
    • Root Cause: app.set('trust proxy', 'loopback') – das passt nur, wenn der Proxy auf 127.0.0.1 lĂ€uft. NPM lĂ€uft aber auf einem anderen Host, also wurde X-Forwarded-For ignoriert.
    • Fix: trust-proxy abhĂ€ngig von HTTPS_ENABLED: HTTPS_ENABLED=true → 1 (genau 1 Hop, der NPM), sonst loopback (Direkt-Verbindungen lokal).
    • Live-verifiziert: req.ip zeigt jetzt die echte Browser-IP statt der NPM-IP, Threshold-Events triggern korrekt.
  • 🚹 KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)

    • Realer Angriff erfolgreich durchgespielt: Portal-User konnte ĂŒber /api/stressfrei-emails/{id}/credentials die kompletten Klartext- IMAP/SMTP-Zugangsdaten der Mailbox eines anderen Kunden abrufen.
    • Root Cause: der Haupt-Endpoint GET /:id hatte canAccessStressfreiEmail-Check, die 8 Sub-Endpoints unter :id/* hatten alle KEINEN Ownership-Check (nur authenticate + requirePermission('customers:read'), was Portal-User von Haus aus haben).
    • Fix: canAccessStressfreiEmail(req, res, id) als erste Zeile in allen 9 betroffenen Controllern: getMailboxCredentials, getFolderCounts, syncAccount, sendEmailFromAccount, enableMailbox, syncMailboxStatus, resetPassword, updateEmail, deleteEmail.
    • Security-Monitor: canAccessResourceByCustomerId emittiert bei jedem Fehlversuch automatisch ein ACCESS_DENIED MEDIUM-Event → Threshold-Detection (>5 in 5 min) erzeugt CRITICAL SUSPICIOUS + Sofort-Alert.
    • Live-verifiziert: Portal-User Kunde A probiert Email-ID von Kunde B durch alle 8 Sub-Routes → alle 8× HTTP 403, eigene Email-ID kommt sauber durch (200/400), 8× ACCESS_DENIED-Events im Security-Monitor.
  • đŸ›Ąïž JWT-Tokens raus aus localStorage – Refresh-Cookie-Pattern

    • Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS könnte JS den Token klauen + alle Anbieter-Credentials abrufen. Lösung: Branchenstandard fĂŒr SPAs.
    • Access-Token: kurzlebig (15 min), lebt nur im JavaScript-Memory (Modul-State + AuthContext). Kein localStorage mehr → XSS-Angriff klaut maximal einen 15-min-Token, mit dem er eh nicht weit kommt.
    • Refresh-Token: 7 Tage Lifetime, im httpOnly-Cookie (Secure bei HTTPS_ENABLED, SameSite=Strict, Path=/api/auth). JavaScript hat keinen Zugriff → XSS kann ihn nicht klauen.
    • Backend:
      • signAccessToken/signRefreshToken mit type-Claim als Unterscheidung; Auth-Middleware lĂ€sst nur type=access durch
      • Login + Customer-Login setzen Cookie + geben Access im Body
      • POST /api/auth/refresh liest Cookie, gibt neuen Access aus, rotiert Refresh-Cookie, prĂŒft tokenInvalidatedAt (sofortige Invalidation bei Rolle-Ändern/Logout)
      • Logout löscht Cookie + setzt tokenInvalidatedAt
      • cookie-parser als neue dependency
    • Frontend:
      • api.ts: in-memory tokenStore + axios-Interceptor mit Auto-Refresh-Retry bei 401 (single-flight gegen Concurrent-Requests)
      • AuthContext: beim App-Start /auth/refresh aufrufen → wenn Cookie noch gĂŒltig, ist der User automatisch eingeloggt (kein Re-Login nach Tab-Reload trotz memory-only Access-Token)
      • 9 alte localStorage.getItem('token')-Stellen migriert auf getAccessToken() (PDF-Vorschau-iframe, Audit-Log-Export, Backup-Download, File-Download-URL, 
)
    • Live verifiziert: Login setzt Cookie+Bearer, API-Calls mit Bearer→200, ohne→401, Refresh-Endpoint rotiert Cookie sauber, Refresh-Token wird als Bearer (Access) abgelehnt („Falscher Token-Typ"), Logout löscht Cookie + invalidiert Token.
  • 🔒 Audit-Log fĂŒr alle Klartext-Passwort-Reads

    • Pentest-Finding „Klartext-Passwörter ĂŒber API abrufbar (HIGH, post-auth)" → reversible VerschlĂŒsselung ist by-design (Feature „Anbieter-Login anzeigen" braucht es), aber jeder Decrypt-Vorgang sollte im Audit-Log auftauchen. Bisher: keiner der 6 Endpoints schrieb ein Log.
    • Audit-Logs jetzt fĂŒr: getPortalPassword, getContractPassword, getSimCardCredentials, getInternetCredentials, getSipCredentials, getMailboxCredentials.
    • action: 'READ', eigene Resource-Types (PortalPassword, ContractPassword, SimCardCredentials, InternetCredentials, SipCredentials, MailboxCredentials), alle mit sensitivity: CRITICAL ĂŒber die Sensitivity-Map.
    • Label nennt explizit „Klartext 
 entschlĂŒsselt" + Ressourcen-ID, damit im Audit-Log-Viewer auf einen Blick erkennbar ist, was passiert ist (DSGVO-Nachvollziehbarkeit + Insider-Threat-Erkennung).
  • ↗ E-Mail-Postfach: Weiterleiten + Erneut senden

    • Weiterleiten (Compose-Modal-Erweiterung): neuer Button im EmailDetail öffnet das ComposeEmailModal im Forward-Modus – To-Feld leer (User trĂ€gt den neuen EmpfĂ€nger ein), Betreff mit „Fwd:"-Prefix, Body mit zitierten Original-Headern (Von, An, Datum, Betreff) + Original-Text.
    • Erneut senden (One-Click): schickt die Mail noch einmal an die ursprĂŒngliche EmpfĂ€nger-Adresse (= die Stressfrei-Adresse selbst). Damit lĂ€uft sie durch die heute hinterlegten Forwards und landet beim aktuell konfigurierten Kunden-Postfach – Use-Case: Stressfrei-Adresse wurde nach Empfang umgestellt, Original ist nur in der alten Inbox. Confirm-Dialog mit Hinweis, dass AnhĂ€nge nicht erneut mit gesendet werden (Weiterleiten dafĂŒr nutzen). Toast fĂŒr Erfolg/Fehler.
  • 🔍 E-Mail-Postfach: Suche + erweiterte Filter (Variante B)

    • Suchleiste ĂŒber der Email-Liste – durchsucht parallel Subject, From-Address/Name und Body.
    • Filter-Button mit Badge (Anzahl aktiver Filter) klappt eine Box mit Detail-Filtern auf: Von, An, Betreff, Inhalt, Datum von/bis, Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status. Alle Filter werden im Backend mit UND verknĂŒpft.
    • „Alle zurĂŒcksetzen"-Button rĂ€umt komplett auf.
    • Backend: GET /api/customers/:id/emails nimmt die Filter als Query-Parameter entgegen, getCachedEmails ĂŒbersetzt sie in eine Prisma where-Klausel.
    • Bewusst nicht gebaut: voller AND/OR-Builder mit Plus-Button und Bool-Verschachtelung – Trade-off-Diskussion mit User: reale Use-Cases sind quasi immer AND, UI-KomplexitĂ€t verschachtelter Bool-Builder bringt mehr Bedienprobleme als Mehrwert.
  • 🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren

    • Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse (Tooltip erklĂ€rt: „ersetzt die Forwards am Provider durch Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach Änderung der Stamm-E-Mail eines Kunden, oder nach Wechsel der defaultForwardEmail in den Provider-Settings.
    • Bei hasMailbox: true wird zusĂ€tzlich das im CRM verschlĂŒsselt hinterlegte Mailbox-Passwort am Provider neu gesetzt. Self-Healing fĂŒr den Fall, dass jemand im Plesk-UI manuell ein anderes Passwort gesetzt hat und IMAP/SMTP im CRM nicht mehr passt.
    • Backend nutzt Plesk's updateForwardTargets (set:email1,email2 → ersetzt komplett, idempotent) + bei Mailbox auch updateMailboxPassword (Plesk-Passwort-Update).
    • Endpoint: POST /api/stressfrei-emails/:id/sync-forwarding, customers:update-Permission, Audit-Log mit Forward-Targets + Passwort-Reset-Marker.
    • Self-Healing: isProvisioned-Flag wird bei erfolgreichem Provider-Aufruf automatisch auf true korrigiert (historischer Bug: Flag wurde beim createEmail mit provisionAtProvider: true nie gesetzt – jetzt behoben + Backfill via Sync).
    • Erfolgs-/Fehler-Meldungen via react-hot-toast (statt alert()) mit Liste der gesetzten Forward-Targets + Hinweis ob Passwort-Reset durchgefĂŒhrt wurde.
    • In der Kundenakte (Stammdaten → Kontakt → E-Mail) externes Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden öffnet – sichtbar nur wenn Stressfrei-Adressen vorhanden sind.
  • đŸ›Ąïž Pentest-Hardening-Runde 11: Header-Hygiene

    • HSTS-Doppel-Header (18× low im Audit): Helmet's Strict-Transport-Security komplett deaktiviert. Der Nginx Proxy Manager vor der CRM-VM setzt HSTS bereits, doppelter Header verletzte RFC 6797.
    • Cache-Control (≄10× info im Audit): /api/* bekommt no-store (sensible JSON-Daten), SPA-HTML (/, /sitemap.xml, /robots.txt, /vite.svg) bekommt no-store, must-revalidate (sonst hĂ€ngt Browser an alter index.html fest nach Deploy), /assets/* (Vite-Build mit Content-Hash im Filename) bekommt public, max-age=31536000, immutable.
    • CSP No-Fallback-Direktiven (2× medium): worker-src, manifest-src, media-src explizit auf 'self' – ZAP markiert sonst „Failure to Define Directive with No Fallback".
    • Bewusst NICHT angefasst: style-src 'unsafe-inline' (Tailwind/React- inline-styles, kompletter Refactor unverhĂ€ltnismĂ€ĂŸig).
    • Live verifiziert: Headers fĂŒr /, /api/*, /assets/*.js und SPA- Fallback-Pfade alle wie erwartet.
  • 🐛 PDF-Vorschau im PDF-Template-Editor lĂ€dt nicht

    • CSP-Direktive frame-ancestors 'none' blockte ALLE iframe-Embeddings der eigenen Resourcen, auch same-origin – Browser zeigte je nach Variante "Verbindung abgelehnt" oder CSP-Violation.
    • Fix: frame-ancestors 'self' (statt 'none'). App darf eigene Resourcen embeden (z.B. die annotierte PDF-Vorschau), externe Sites bleiben weiterhin gesperrt.
  • 🔁 Factory-Defaults Sync-Scripts (dev ↔ prod ↔ Image)

    • ./factory-export.sh zieht eine ZIP per API in factory-exports/ (gitignored Drop-Box).
    • ./factory-import.sh [zip] lĂ€dt die ZIP per API in eine andere Instanz – ohne Argument wĂ€hlt es die jĂŒngste ZIP automatisch.
    • ./factory-import.sh --save-as-builtin entpackt die ZIP zusĂ€tzlich nach backend/factory-defaults/ (vorher aufgerĂ€umt). Damit landet sie beim nĂ€chsten docker-compose up --build als Werkseinstellung im Image und seedet frische DBs automatisch.
    • Konfigurierbar per Env: OPENCRM_URL, OPENCRM_EMAIL, OPENCRM_PASSWORD (sonst interaktive Abfrage).
    • README-Abschnitt „Factory-Defaults: Stammdaten-Kataloge teilen" komplett ĂŒberarbeitet (drei Transport-Pfade, Auto-Seed, Whitelist).
  • 🚀 Auto-Seed: Werkseinstellungen beim Erst-Deploy

    • Inhalt von backend/factory-defaults/ wird via Dockerfile als /app/factory-defaults-builtin/ ins Image gebrannt.
    • Entrypoint spielt sie nach erfolgreichem Auto-Seed (frische DB) automatisch via tsx scripts/seed-factory-defaults.ts ein – steuerbar ĂŒber FACTORY_DEFAULTS_DIR.
    • Damit bringen neue VMs sofort Anbieter, Tarife, PDF-Auftragsvorlagen + DatenschutzerklĂ€rung/Impressum mit, ohne manuelles UI-/CLI-Import.
    • Bestehende Installs werden NIE ĂŒberschrieben (Trigger nur wenn der Auto-Seed im selben Start-Lauf gelaufen ist).
  • 📩 Factory-Defaults: HTML-Templates + Import via UI

    • DatenschutzerklĂ€rung, Impressum, Vollmacht-Vorlage und Website-Datenschutz werden jetzt mit ins Factory-Defaults-ZIP gepackt (app-settings/-Ordner, Whitelist-geschĂŒtzt – andere AppSetting-Keys werden ignoriert).
    • Import lĂ€uft jetzt auch ĂŒber die UI (Einstellungen → Factory-Defaults → „ZIP hochladen"). Der CLI-Weg npm run seed:defaults bleibt erhalten und wurde gleichermaßen um die HTML-Templates erweitert.
    • Zwei-Wege-Roundtrip live verifiziert: Export → AppSetting löschen → Import → Wert wieder vollstĂ€ndig hergestellt; Counts in Audit-Log.
  • 🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar

    • Mass-Assignment-Whitelist (pickUserUpdate) hat hasGdprAccess / hasDeveloperAccess rausgefiltert → Service erhielt sie nie → Rollen DSGVO/Developer waren in der UI nicht zuweisbar (Checkbox ohne Wirkung).
    • Beide Felder zur Whitelist hinzugefĂŒgt + Audit-Log liest die Pre-Werte jetzt aus den geladenen Rollen (kein False-Positive-Change mehr).
  • 🔒 HTTPS-only-Header per Flag (HTTPS_ENABLED)

    • HSTS + upgrade-insecure-requests (CSP) sperrten den Browser bei direktem http://ip:port-Zugriff aus (ERR_SSL_PROTOCOL_ERROR).
    • Beide Header default OFF, kommen nur mit HTTPS_ENABLED=true (sobald TLS-Reverse-Proxy davor steht).
  • đŸ—ƒïž Prisma-Migrations-System (statt db push)

    • Initial-Migration 0_init aus aktuellem Schema generiert (prisma migrate diff --from-empty --to-schema-datamodel).
    • 24 alte gedriftete Migrations gelöscht – frischer Start.
    • migration_lock.toml fĂŒr MySQL hinzugefĂŒgt.
    • Container-Entrypoint umgebaut:
      • Auto-Baseline-Detection: bestehende DB ohne _prisma_migrations → migrate resolve --applied 0_init lĂ€uft automatisch.
      • Statt db push --accept-data-loss jetzt migrate deploy (idempotent, datenerhaltend, keine stillen DROPs mehr).
    • Neuer npm-Script schema:sync (lokal/Dev): legt automatisch eine versionierte Migration mit Zeitstempel-Namen an (prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)).
    • Workflow ab jetzt: schema.prisma Ă€ndern → npm run schema:sync → Migration committen → Push → Container-Restart wendet sie automatisch an.
  • 🔄 Automatische Vertrags-Status-ÜbergĂ€nge

    • Nightly-Cron (02:00 + Catch-up 60s nach Start): alle VertrĂ€ge mit status=ACTIVE und endDate < heute → EXPIRED (mit Audit-Log).
    • Beim Upload der KĂŒndigungsbestĂ€tigung (cancellationConfirmationPath): wenn Vertrag aktuell ACTIVE → auf CANCELLED setzen (Audit-Log). Frontend fragt per Modal das BestĂ€tigungs-Datum ab (Default: heute), wird direkt als cancellationConfirmationDate gespeichert. Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er fĂŒr VertragsĂ€nderungen (nicht echte KĂŒndigungen) gedacht ist, setzt aber cancellationConfirmationOptionsDate analog.
    • Beim Upload einer LieferbestĂ€tigung (ContractDocument via direkt-Upload oder Email-Anhang-Import): wenn Vertrag aktuell DRAFT → auf ACTIVE setzen + startDate auf das erfasste Lieferdatum (falls leer). Frontend zeigt Datums-Input conditional, wenn Typ "LieferbestĂ€tigung" ausgewĂ€hlt ist.
    • Keine neuen Status eingefĂŒhrt: cancellationSentDate vs. cancellationConfirmationDate genĂŒgen, um "gesendet vs. bestĂ€tigt" abzubilden. ACTIVE bleibt bis zur BestĂ€tigung.
  • đŸ›Ąïž Security-Hardening vor Production-Deployment (10 Runden)

    • VollstĂ€ndige Story inkl. aller Live-Test-Tabellen + Trade-offs: SECURITY-HARDENING.md
    • Erste 2 Runden zusĂ€tzlich ausfĂŒhrlich in SECURITY-REVIEW.md
    • Highlights:
      • Runde 1–3: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
      • Runde 4: 9 Live-IDORs (customer.*/gdpr.*) + Error-Handler
      • Runde 5: /api/uploads-Auth (DSGVO-GAU), Login-Timing, Privacy-Policy-XSS
      • Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass, Self-Grant + Existence-Disclosure
      • Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
      • Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
      • Runde 9: npm audit fix (8 Vulns weg), Audit-Chain-Rehash, keine neuen Critical-Findings → diminishing returns erreicht
      • Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection + Sofort-Alert fĂŒr CRITICAL + Hourly-Digest + UI in Einstellungen)
    • Deployment-Checkliste komplett (in HARDENING.md)
  • 🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-GeburtstagsgrĂŒĂŸe

    • Passwort vergessen-Flow (Login → "Passwort vergessen?" Link)
      • Email-Reset-Token mit 2h GĂŒltigkeit (kryptografisch sicher: 32 Byte Random)
      • Funktioniert fĂŒr Mitarbeiter UND Portal-Kunden (Typ-Auswahl)
      • User-Enumeration-Schutz: immer 200 OK, egal ob Email existiert
      • Reset-Link per Email mit schönem HTML-Template
      • Nach Reset: alle bestehenden Sessions werden gekickt
    • Rate-Limiting gegen Brute-Force
      • Login: 10 Versuche pro 15 Min pro IP (erfolgreiche zĂ€hlen nicht)
      • Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP
    • Cron-Job fĂŒr automatische GeburtstagsgrĂŒĂŸe
      • TĂ€glich 08:00 Uhr: alle Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
      • Email-Versand ĂŒber System-E-Mail, Du/Sie-abhĂ€ngiger Text
      • Catch-up 30s nach Server-Start (falls Server am Geburtstag kurz down war)
      • Marker lastBirthdayGreetingYear verhindert Doppel-Versand
  • MandantenfĂ€higkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider

    • Neues Feld customerEmailLabel am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma")
    • Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" → "Stressfrei-Wechseln")
    • Neuer Frontend-Hook useProviderSettings() liefert Domain + Label
    • Alle hardcoded "Stressfrei-Wechseln" und @stressfrei-wechseln.de Strings durch dynamische Werte ersetzt (CustomerDetail, ContractForm, ContractDetail, EmailClientTab, Settings)
    • Modal-Eingabefeld "Bezeichnung fĂŒr Kunden-E-Mails" in Provider-Einstellungen
    • Notwendig fĂŒr Multi-Mandanten-Betrieb wenn das CRM an Dritte vermietet wird
  • Factory-Defaults: Export + Import von Stammdaten-Katalogen

    • EnthĂ€lt: Anbieter, Tarife, KĂŒndigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
    • EnthĂ€lt NICHT: Kundendaten, VertrĂ€ge, Dokumente, Emails, Einstellungen (dafĂŒr gibt es den Datenbank-Backup)
    • Neue Einstellungsseite „Factory-Defaults" mit Übersicht (Anzahl pro Kategorie) und Export-Button
    • Export: ZIP mit manifest.json + Kategorie-JSONs + PDF-Dateien, Download ĂŒber Browser
    • Import-Script: npm run seed:defaults liest backend/factory-defaults/, merged mehrere JSONs pro Kategorie, upsertet idempotent + kopiert PDFs in uploads/
    • Ordner backend/factory-defaults/ gitignoriert (außer .gitkeep + README), damit firmen-spezifische Kataloge nicht ins Repo kommen
  • Email-AnhĂ€nge → Vertragsdokumente + Rechnungen fĂŒr alle Vertragstypen

    • Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
      1. Als Dokument (in feste Slots wie KĂŒndigungsschreiben) – wie bisher
      2. Als Vertragsdokument – neu, mit Typ-Dropdown (Auftragsformular, LieferbestĂ€tigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
      3. Als Rechnung – jetzt fĂŒr alle Vertragstypen (vorher nur Strom/Gas)
    • Gleiches gilt fĂŒr das Speichern der gesamten Email als PDF-Rechnung
    • Neuer Backend-Endpoint saveAttachmentAsContractDocument fĂŒr die flexible ContractDocument-Tabelle
  • Geburtstag-Management-Modal in Kundenstammdaten

    • Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
    • Gruß zurĂŒcksetzen: setzt lastBirthdayGreetingYear auf null zurĂŒck (fĂŒrs Debugging + Fallback)
    • Gruß jetzt senden: per Email (direkt), WhatsApp/Telegram/Signal (öffnet vorbefĂŒlltes Fenster)
    • Beide Aktionen mit Ja/Nein-BestĂ€tigungsdialog (kein versehentliches Klicken)
    • Text respektiert Du/Sie-Einstellung des Kunden
    • Checkbox "Automatisch senden" mit Kanal-Dropdown (neue Felder am Customer)
    • Audit-Log fĂŒr Reset + Send
  • Anrede-VerhĂ€ltnis Du/Sie pro Kunde

    • Neues Feld useInformalAddress in Stammdaten (auch bei Firmenkunden)
    • Default: Sie (formell)
    • Geburtstagsgruß im Portal nutzt die Anrede: "Du"-Kunden bekommen "Herzlichen GlĂŒckwunsch, Max!", "Sie"-Kunden "Herzlichen GlĂŒckwunsch, Herr MĂŒller!"
    • Komplett konsistent auch bei nachtrĂ€glichen GlĂŒckwĂŒnschen ("hattest" vs "hatten")
  • Geburtsdatum + Geburtsort auch bei Firmenkunden

    • Felder werden jetzt unabhĂ€ngig vom Kundentyp angezeigt
    • Ermöglicht z.B. Geburtstage fĂŒr Ansprechpartner bei Firmen
  • Geburtstagskalender + Geburtstagsgruß-Modal

    • Admin: Section im Vertrags-Cockpit mit Kunden, die in den nĂ€chsten 30 Tagen oder letzten 7 Tagen Geburtstag haben
    • Portal: Modal mit Gruß am Geburtstag (inkl. nachtrĂ€glichem GlĂŒckwunsch bis 7 Tage danach)
    • Wird pro Jahr nur einmal angezeigt
  • Typspezifische Zusatzinfos in Vertragslisten

    • Strom/Gas → "Lieferadresse: ..."
    • DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
    • Mobilfunk → "Rufnummer: ..."
    • KFZ → "Kennzeichen: ..."
    • Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
  • DatenschutzerklĂ€rung PDF ↔ Online-Einwilligungen synchronisieren

    • PDF hochgeladen → alle 4 Consents auf GRANTED
    • Haken entfernt im Portal → PDF löschen + Tabs sperren
    • Entsperrung nur durch alle Haken oder neues PDF
  • Zweitarif-ZĂ€hler (HT/NT) bei Strom + Verbrauchsberechnung

  • Datumsformate vereinheitlichen (01.01.2026 statt 1.1.2026)

  • Audit-Log aussagekrĂ€ftig (Vorher/Nachher bei allen Änderungen)

  • Impressum + Website-DatenschutzerklĂ€rung im Kundenportal

    • Editor in Einstellungen
    • Vorschlagstexte
  • Consent-BestĂ€tigungs-Flow per Email

    • Alle Hebel mĂŒssen gesetzt sein
    • BestĂ€tigungsbutton + BestĂ€tigungsemail
  • Vertragsdokumente-Upload (Auftragsformular, LieferbestĂ€tigung, Vertragsunterlagen als PDF/PNG)

  • Bug: Stressfrei-Email im Auftragsgenerator (funktioniert jetzt im Vertrag)

  • PDF-Auftragsvorlagen-System

    • Template-Editor in Einstellungen
    • PDF hochladen, Formularfelder automatisch auslesen
    • CRM-Felder zuordnen (visuell mit Vorschau)
    • Seitenweise Sortierung der Felder
    • Dynamische Rufnummern-Felder mit Vorwahl-Extraktion
    • Nicht zugeordnete Felder bleiben editierbar
    • Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
  • EigentĂŒmer-Verwaltung

    • An Adresse gehĂ€ngt (Firma, Vorname, Nachname, Anschrift, Kontakt)
    • Fallback auf Kundendaten wenn leer
    • Nur bei Liefer-/Meldeadressen (nicht Rechnung)
    • Namens-Kombinationen (Firma + Vorname + Nachname etc.)
  • Gruppenauswahl Liefer-/Rechnungs-/EigentĂŒmer-Adresse im Auftragsgenerator

  • Objekttyp + Lage + Lage des Anschlusses bei Festnetz-VertrĂ€gen (DSL/Glasfaser/Kabel)

  • Bankverbindung-Fallback im PDF-Generator (neueste aktive Bankverbindung des Kunden)