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

704 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📋 OpenCRM Todo-Liste
---
## 🔜 Offen
### Manuelle Tests (vor Release durchklicken)
Checklisten für Security + Email-Log-System stehen in **[TESTING.md](./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
- [x] **🚨 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.
- [x] **🚨 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.
- [x] **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.
- [x] **🚨 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.
- [x] **🚨 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.
- [x] **🔐 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.
- [x] **🔐 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.
- [x] **🌐 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.
- [x] **🚨 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.
- [x] **🛡️ 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.
- [x] **🔒 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).
- [x] **↗ 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.
- [x] **🔍 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.
- [x] **🔁 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.
- [x] **🛡️ 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.
- [x] **🐛 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.
- [x] **🔁 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).
- [x] **🚀 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).
- [x] **📦 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.
- [x] **🐛 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).
- [x] **🔒 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).
- [x] **🗃️ 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.
- [x] **🔄 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.
- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)**
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
- Erste 2 Runden zusätzlich ausführlich in
[SECURITY-REVIEW.md](./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)
- [x] **🎉 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
- [x] **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
- [x] **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
- [x] **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
- [x] **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
- [x] **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")
- [x] **Geburtsdatum + Geburtsort auch bei Firmenkunden**
- Felder werden jetzt unabhängig vom Kundentyp angezeigt
- Ermöglicht z.B. Geburtstage für Ansprechpartner bei Firmen
- [x] **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
- [x] **Typspezifische Zusatzinfos in Vertragslisten**
- Strom/Gas → "Lieferadresse: ..."
- DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
- Mobilfunk → "Rufnummer: ..."
- KFZ → "Kennzeichen: ..."
- Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
- [x] **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
- [x] **Zweitarif-Zähler (HT/NT)** bei Strom + Verbrauchsberechnung
- [x] **Datumsformate vereinheitlichen** (01.01.2026 statt 1.1.2026)
- [x] **Audit-Log aussagekräftig** (Vorher/Nachher bei allen Änderungen)
- [x] **Impressum + Website-Datenschutzerklärung** im Kundenportal
- Editor in Einstellungen
- Vorschlagstexte
- [x] **Consent-Bestätigungs-Flow per Email**
- Alle Hebel müssen gesetzt sein
- Bestätigungsbutton + Bestätigungsemail
- [x] **Vertragsdokumente-Upload** (Auftragsformular, Lieferbestätigung, Vertragsunterlagen als PDF/PNG)
- [x] **Bug: Stressfrei-Email im Auftragsgenerator** (funktioniert jetzt im Vertrag)
- [x] **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)
- [x] **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.)
- [x] **Gruppenauswahl Liefer-/Rechnungs-/Eigentümer-Adresse** im Auftragsgenerator
- [x] **Objekttyp + Lage + Lage des Anschlusses** bei Festnetz-Verträgen (DSL/Glasfaser/Kabel)
- [x] **Bankverbindung-Fallback** im PDF-Generator (neueste aktive Bankverbindung des Kunden)