Files
opencrm/docs/todo.md
T
duffyduck d5dd3f5e7f Pentest 70.2 (LOW): 500 statt 415 bei verbotenem MIME
Globaler Error-Handler (index.ts:461) matcht /sind erlaubt|nicht
erlaubt/i auf 415. Die 70.1-Reject-Message "... WebP erlaubt" (ohne
"sind") rutschte durch und landete bei 500 + Error-Log-Spam.

Fix: "... WebP-Dateien sind erlaubt" macht den Regex happy. Andere
Routes nutzen alle schon dieselbe Phrase.
2026-06-03 15:32:34 +02:00

98 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

  • 🔒 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)