Files
opencrm/docs/todo.md
T
duffyduck a982795388 Security-Hardening Runde 10: Pentest Runde 6 (8 Findings + struktureller Audit-Sweep)
KRITISCH:
- emails/:id/thread bekommt canAccessCachedEmail
- customers/:customerId/representatives/search bekommt canAccessCustomer
  (Buchstaben-Brute-Force konnte sonst die Kunden-DB enumerieren)

HOCH:
- birthdays/upcoming: Portal-User → 403 (Name/E-Mail/Telefon/Geb-Datum
  aller Kunden leakte)
- contracts/:id/history (GET/POST/PUT/DELETE) bekommt canAccessContract
- mailbox-accounts / unread-count / contracts/:id/emails/folder-counts
  bekommen canAccessCustomer bzw. canAccessContract
- Vertreter-Vollmacht-Check ist jetzt live: neuer Helper
  getPortalAllowedCustomerIds() in accessControl.ts ruft
  hasAuthorization() für jedes vertretene Customer ab. Eingesetzt in
  getTasks/createSupportTicket/createCustomerReply/getAllTasks/
  getTaskStats und updateCustomerConsent. Widerrufene Vollmachten
  haben jetzt SOFORT keinen Zugriff mehr (vorher: bis JWT abläuft).

MITTEL:
- confirmPasswordReset speichert portalPasswordEncrypted nicht mehr
  beim Self-Service-Reset (war nur für Admin-OTPs gedacht); +
  portalPasswordMustChange=false explizit
- getCustomers pagination total reflektiert jetzt nur erlaubte IDs
  (über DB-Filter in customerService.getAllCustomers)

Audit-Sweep (defense in depth, falls Rolle versehentlich Update-
Permissions bekommt):
- 16 cachedEmail-Operationen (markAsRead, toggleStar, assign/unassign,
  save-as-pdf/invoice/contract-document, save-to, attachment-targets,
  trash-ops)
- 4 contract-Operationen (createFollowUp, createRenewal, snoozeContract,
  removeContractMeter)
- 12 sub-CRUD-Operationen (address/bankcard/document/meter
  update+delete, meter-reading add/update/delete/transfer)
- 2 representative-Operationen (add/remove)

Live-verifiziert: Portal-Customer-3 auf alle fremden IDs → 403,
Admin sieht alles, eigene Ressourcen weiterhin 200, Customer 1 mit
widerrufener Vollmacht für Customer 3 → 0 fremde Verträge in der
Response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:47:17 +02:00

38 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 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=true1 (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_migrationsmigrate 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 < heuteEXPIRED (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 13: 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)