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>
38 KiB
📋 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
tenantIdim 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.devia 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 lassenpayment_failed→ Banner im CRM, nach X Tagen pausierenmandate_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):
- Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain)
- Admin-Portal: Trial-Instanz starten
- DB erstellen, Docker-Container hochfahren, Caddy-Config für Subdomain
- Einladungs-Email mit Admin-Login + Passwort-Reset-Link
- Tag 25: Erinnerungs-Email "Deine Trial läuft bald ab"
- Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren"
- Kunde erfasst GoCardless-Mandat im Admin-Portal-Login
- Bei erfolgreicher Zahlung: Instanz bleibt aktiv
- 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.ymlpro 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:canAccessCachedEmailim Controller. - KRITISCH-02
GET /customers/:customerId/representatives/search: keincanAccessCustomerauf 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:canAccessContractin allen vier History-Handlern. - HOCH-03 Mailbox-Endpoints:
mailbox-accounts,unread-count,contracts/:id/emails/folder-countsohne Check. Fix:canAccessCustomerbzw.canAccessContractin allen drei. - HOCH-04 Live-Vollmacht-Check in Tasks:
getTasks,createSupportTicket,createCustomerReply,getAllTasks,getTaskStatsprüften nurrepresentedCustomerIds.includes(...)aus dem JWT – widerrufene Vollmachten hatten weiter Zugriff (JWT lebt bis zu 15min nach Widerruf). Neuer HelpergetPortalAllowedCustomerIds()inaccessControl.tsrufthasAuthorization()live ab. AuchupdateCustomerConsent(GDPR) auf diesen Pfad umgestellt. - MITTEL-01
confirmPasswordResetKlartext-Speicherung: Self-Service-Reset speicherteportalPasswordEncrypted = encrypt(pw). Klartext-Speicherung ist nur für Admin-OTPs sinnvoll. Fix: Field auf null, zusätzlichportalPasswordMustChange = false. - MITTEL-02 Pagination-Total leakt globale Kunden-Anzahl:
GET /customersgabtotal: 4271auch wenn Portal-User nur 1 Kunde sah. Fix:customer.service.tserweitert umallowedIds-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, eigenecustomers/3→ 200, pagination.total für Portal = 1 (statt 3), Customer 1 mit widerrufener Vollmacht → 0 fremde Verträge.
- KRITISCH-01
-
🚨 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- passwordaufrufen und das eigene Passwort ohne Kenntnis des alten ersetzen. Der OTP-Flow-Endpoint hatte den CheckportalPasswordMustChange === truenicht. - Konsequenz: Bei XSS oder kurzlebigem Token-Diebstahl konnte ein Angreifer das Passwort dauerhaft übernehmen.
- Fix: Eine Zeile in
auth.controller.ts–prisma.customer.findUniqueaufportalPasswordMustChange, beifalse→ 403 "Nicht erlaubt". - Live-verifiziert: ohne Flag → 403; mit Flag (nach
send-credentials) → 200, danach Flag automatisch zurück auf
false→ erneuter Aufruf → 403.
- Realer Angriff: Jeder Portal-User konnte jederzeit mit
seinem eingeloggten Token
-
Pentest Runde 5 – NIEDRIG: consentHash + Public-Grant-Response
consentHashwurde überGET /api/customers/:idzurü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: inSENSITIVE_CUSTOMER_FIELDSaufgenommen. Wer ihn legitim braucht, holt ihn über/gdpr/customer/:id/consent-status(eigener Check).POST /api/public/consent/:hash/grantgab den vollenCustomerConsent[]-Array inkl. IP-Adressen undcreatedBy(Kunden-Name) zurück. Fix: Response auf{ granted: <count> }reduziert. Frontend nutzt eh nursuccess-Flag.- Live-verifiziert:
consentHash: nullin customer-Response,consentHashweiterhin 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. Diecontracts:read-Permission haben aber auch Portal-User → Endpoint war erreichbar. - Fix: Service-Signatur erweitert auf
getCockpitData({ customerIds? }). WenncustomerIdsgesetzt sind, werden Haupt-Vertrags-Query, Consent-Maps, Ausweis- Warnungen und gemeldete Zählerstände allesamt auf diese IDs eingeschränkt. Controller bestimmtcustomerIdsanalog zugetContracts: beiisCustomerPortal→ 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.
- Realer Angriff: Portal-User Max bekam mit seinem Token
-
🚨 Pentest Runde 3 – drei Findings gefixt
- KRITISCH –
POST /api/developer/setupohne Auth (Privilege Escalation): Endpoint war komplett ohne Authentifizierung erreichbar und konnte der Admin-Rolle diedeveloper:access- Permission verleihen → kompletter DB-Zugriff über/developer/*. Fix: Endpoint ersatzlos gelöscht. Manuelles Setzen geht weiterhin überprisma/add-developer-permission.ts(CLI). Live-verifiziert:POST /api/developer/setup→ HTTP 404. - HOCH – Customer-Login DoS auf Prod (fehlende Migration):
portalPasswordMustChangewar im Code, aber prod-DB kannte die Spalte nicht → Prisma warf bei jedem Kunden-Login. Root Cause: in dieser Session wurdeprisma db pushbenutzt (kein Migration- File). Fix: handgenerierte Migration20260516173552_portal_password_must_change/migration.sql(viaprisma migrate diff+migrate resolve --applied). Verifiziert durch shadow-DB-Reset +migrate deploy: Spalte landet korrekt in einer frischen DB.entrypoint.shführtmigrate deploybeim 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.messagedirekt zurückgegeben → Tabellen-/Spaltennamen leakten. Fix: Whitelist-FiltersafeLoginError()inauth.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.
- KRITISCH –
-
🔐 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:
- Admin klickt Zugangsdaten versenden → Flag wird gesetzt, Mail-Template weist explizit auf „Einmalpasswort" hin.
- Kunde loggt sich mit dem OTP ein → Backend gibt
mustChangePassword: trueim Login-Response zurück UND konsumiert das OTP sofort: setztportalPasswordHash = nullundportalPasswordEncrypted = null. Ein zweiter Login mit demselben Passwort schlägt fehl (401). - Frontend (
ProtectedRoute) siehtmustChangePassword=trueund leitet auf/change-initial-passwordum – egal welche Route der Kunde aufrufen will, er kommt nicht weiter. - Auf der Seite gibt er ein neues, komplexes Passwort vor (Live-Hint mit ✓/○, dieselben Regeln wie Backend).
POST /api/auth/change-initial-portal-passwordspeichert neuen Hash, löscht das Encrypted-Feld (Admin kann das eigene Passwort des Kunden nicht mehr im Klartext lesen), setztportalTokenInvalidatedAt = now()undportalPasswordMustChange = false.- 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" →
mustChangewird automatisch wiederfalse. 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()inpasswordGenerator.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 PasswortHallo123!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), sonstloopback(Direkt-Verbindungen lokal). - Live-verifiziert: req.ip zeigt jetzt die echte Browser-IP statt der NPM-IP, Threshold-Events triggern korrekt.
- Problem: Rate-Limiter und Security-Monitor haben statt der
echten Client-IP nur die NPM-IP (
-
🚨 KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)
- Realer Angriff erfolgreich durchgespielt: Portal-User konnte über
/api/stressfrei-emails/{id}/credentialsdie kompletten Klartext- IMAP/SMTP-Zugangsdaten der Mailbox eines anderen Kunden abrufen. - Root Cause: der Haupt-Endpoint
GET /:idhattecanAccessStressfreiEmail-Check, die 8 Sub-Endpoints unter:id/*hatten alle KEINEN Ownership-Check (nurauthenticate + 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:
canAccessResourceByCustomerIdemittiert bei jedem Fehlversuch automatisch einACCESS_DENIED MEDIUM-Event → Threshold-Detection (>5 in 5 min) erzeugtCRITICAL 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.
- Realer Angriff erfolgreich durchgespielt: Portal-User konnte über
-
🛡️ 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 (
Securebei HTTPS_ENABLED,SameSite=Strict,Path=/api/auth). JavaScript hat keinen Zugriff → XSS kann ihn nicht klauen. - Backend:
signAccessToken/signRefreshTokenmittype-Claim als Unterscheidung; Auth-Middleware lässt nurtype=accessdurch- Login + Customer-Login setzen Cookie + geben Access im Body
POST /api/auth/refreshliest Cookie, gibt neuen Access aus, rotiert Refresh-Cookie, prüfttokenInvalidatedAt(sofortige Invalidation bei Rolle-Ändern/Logout)- Logout löscht Cookie + setzt
tokenInvalidatedAt cookie-parserals neue dependency
- Frontend:
api.ts: in-memorytokenStore+ axios-Interceptor mit Auto-Refresh-Retry bei 401 (single-flight gegen Concurrent-Requests)AuthContext: beim App-Start/auth/refreshaufrufen → 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 aufgetAccessToken()(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 mitsensitivity: 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/emailsnimmt die Filter als Query-Parameter entgegen,getCachedEmailsübersetzt sie in eine Prismawhere-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
defaultForwardEmailin den Provider-Settings. - Bei
hasMailbox: truewird 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 auchupdateMailboxPassword(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 auftruekorrigiert (historischer Bug: Flag wurde beimcreateEmailmitprovisionAtProvider: truenie gesetzt – jetzt behoben + Backfill via Sync). - Erfolgs-/Fehler-Meldungen via
react-hot-toast(stattalert()) 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.
- 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
-
🛡️ Pentest-Hardening-Runde 11: Header-Hygiene
- HSTS-Doppel-Header (18× low im Audit): Helmet's
Strict-Transport-Securitykomplett deaktiviert. Der Nginx Proxy Manager vor der CRM-VM setzt HSTS bereits, doppelter Header verletzte RFC 6797. - Cache-Control (≥10× info im Audit):
/api/*bekommtno-store(sensible JSON-Daten), SPA-HTML (/,/sitemap.xml,/robots.txt,/vite.svg) bekommtno-store, must-revalidate(sonst hängt Browser an alter index.html fest nach Deploy),/assets/*(Vite-Build mit Content-Hash im Filename) bekommtpublic, max-age=31536000, immutable. - CSP No-Fallback-Direktiven (2× medium):
worker-src,manifest-src,media-srcexplizit 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/*.jsund SPA- Fallback-Pfade alle wie erwartet.
- HSTS-Doppel-Header (18× low im Audit): Helmet's
-
🐛 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.
- CSP-Direktive
-
🔁 Factory-Defaults Sync-Scripts (dev ↔ prod ↔ Image)
./factory-export.shzieht eine ZIP per API infactory-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-builtinentpackt die ZIP zusätzlich nachbackend/factory-defaults/(vorher aufgeräumt). Damit landet sie beim nächstendocker-compose up --buildals 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.tsein – steuerbar überFACTORY_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).
- Inhalt von
-
📦 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:defaultsbleibt 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.
- Datenschutzerklärung, Impressum, Vollmacht-Vorlage und Website-Datenschutz
werden jetzt mit ins Factory-Defaults-ZIP gepackt (
-
🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar
- Mass-Assignment-Whitelist (
pickUserUpdate) hathasGdprAccess/hasDeveloperAccessrausgefiltert → 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).
- Mass-Assignment-Whitelist (
-
🔒 HTTPS-only-Header per Flag (
HTTPS_ENABLED)- HSTS +
upgrade-insecure-requests(CSP) sperrten den Browser bei direktemhttp://ip:port-Zugriff aus (ERR_SSL_PROTOCOL_ERROR). - Beide Header default OFF, kommen nur mit
HTTPS_ENABLED=true(sobald TLS-Reverse-Proxy davor steht).
- HSTS +
-
🗃️ Prisma-Migrations-System (statt
db push)- Initial-Migration
0_initaus aktuellem Schema generiert (prisma migrate diff --from-empty --to-schema-datamodel). - 24 alte gedriftete Migrations gelöscht – frischer Start.
migration_lock.tomlfür MySQL hinzugefügt.- Container-Entrypoint umgebaut:
- Auto-Baseline-Detection: bestehende DB ohne
_prisma_migrations→migrate resolve --applied 0_initläuft automatisch. - Statt
db push --accept-data-lossjetztmigrate deploy(idempotent, datenerhaltend, keine stillen DROPs mehr).
- Auto-Baseline-Detection: bestehende DB ohne
- 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.
- Initial-Migration
-
🔄 Automatische Vertrags-Status-Übergänge
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
status=ACTIVEundendDate < heute→EXPIRED(mit Audit-Log). - Beim Upload der Kündigungsbestätigung (
cancellationConfirmationPath): wenn Vertrag aktuellACTIVE→ aufCANCELLEDsetzen (Audit-Log). Frontend fragt per Modal das Bestätigungs-Datum ab (Default: heute), wird direkt alscancellationConfirmationDategespeichert. Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er für Vertragsänderungen (nicht echte Kündigungen) gedacht ist, setzt abercancellationConfirmationOptionsDateanalog. - Beim Upload einer
Lieferbestätigung(ContractDocument via direkt-Upload oder Email-Anhang-Import): wenn Vertrag aktuellDRAFT→ aufACTIVEsetzen +startDateauf das erfasste Lieferdatum (falls leer). Frontend zeigt Datums-Input conditional, wenn Typ "Lieferbestätigung" ausgewählt ist. - Keine neuen Status eingeführt:
cancellationSentDatevs.cancellationConfirmationDategenügen, um "gesendet vs. bestätigt" abzubilden.ACTIVEbleibt bis zur Bestätigung.
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
-
🛡️ 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
- Passwort vergessen-Flow (Login → "Passwort vergessen?" Link)
-
Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider
- Neues Feld
customerEmailLabelam 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.deStrings 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
- Neues Feld
-
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:defaultsliestbackend/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:
- Als Dokument (in feste Slots wie Kündigungsschreiben) – wie bisher
- Als Vertragsdokument – neu, mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
- 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
saveAttachmentAsContractDocumentfür die flexible ContractDocument-Tabelle
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
-
Geburtstag-Management-Modal in Kundenstammdaten
- Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
- Gruß zurücksetzen: setzt
lastBirthdayGreetingYearauf 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
useInformalAddressin 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")
- Neues Feld
-
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)