# 📋 OpenCRM – Todo-Liste
---
## 🔜 Offen
### Manuelle Tests (vor Release durchklicken)
Checklisten fĂĽr Security + Email-Log-System stehen in **[docs/TESTING.md](../docs/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] **🔄 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-Review + Hardening vor Production-Deployment (3 Runden)**
- Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)**
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
- CORS offen → `CORS_ORIGINS` explizit
- Helmet + Security-Headers
- JWT-Fallback-Secret entfernt (Fail-Fast beim Start)
- IDOR bei 7 Contract-Endpoints
- XSS via Email-Body (DOMPurify)
- Customer-API Data Exposure (Passwort-Hashes)
- Portal-JWT-Invalidation nach Passwort-Reset
- Body-Size-Limit 5 MB
- **Runde 2 – Deep-Dive mit parallelen Audit-Agents, 5 weitere kritische + 2 wichtige:**
- Zip-Slip im Backup-Upload (Arbitrary File Write!)
- Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!)
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
- Path-Traversal bei Backup-Name und GDPR-Proof-Download
- **Runde 3 – Tiefer Dive (8 weitere Hardenings):**
- JWT algorithm confusion: `jwt.verify` auf `algorithms: ['HS256']` festgenagelt
- `trust proxy = 1` fĂĽr Rate-Limiter hinter Reverse-Proxy (sonst unwirksam)
- IDOR Invoice (alte `/api/energy-details/:ecdId/invoices`): jetzt `canAccessEnergyContractDetails` → Contract → customerId
- IDOR PDF-Template-Generator (`:id/generate/:contractId`): jetzt `canAccessContract`
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schĂĽtzt alle Caller)
- bcrypt cost 10 → 12 (OWASP 2026)
- **Runde 8 – Loose Ends (DNS-Rebinding + Per-File-Ownership):**
- **DNS-Rebinding-Schutz** in test-connection / test-mail-access:
Hostnames werden vor Connect via `dns.resolve4/6` aufgelöst und
jede IP gegen die SSRF-Block-Liste geprüft. Connection läuft
anschlieĂźend gegen die IP, der ursprĂĽngliche Hostname als
`tls.servername` fĂĽr SNI/Cert-Validation. Ein zweiter DNS-Lookup
kann keine geblockte IP unterschieben.
- **Per-File-Ownership-Check** statt freiem static-Handler:
`app.use('/api/uploads', authenticate, express.static)` wird
ersetzt durch `GET /api/files/download?path=...`. Der
Controller mappt den Pfad via DB-Lookup auf Customer/Contract
und delegiert an `canAccessCustomer`/`canAccessContract` –
ein eingeloggter Portal-Kunde kann jetzt nur seine eigenen
(oder vertretene mit Vollmacht) Dateien laden, selbst wenn
er fremde Filenames irgendwo mitgeschnitten hätte.
`/api/uploads/*` bleibt als Backwards-Compat-Shim erhalten,
ruft aber denselben Owner-Check.
- 12 subDir-Mappings: bank-cards, documents, business-/commercial-/
privacy-, authorizations, contract-documents, invoices, alle
4 cancellation-* + pdf-templates (admin-only).
- Frontend `fileUrl()` zeigt jetzt auf den neuen Endpoint.
Path-Traversal wird sowohl per Format-Validation (begin /uploads/,
no '..') als auch durch absoluten Path-Vergleich gegen uploadsRoot
geblockt.
- Live-verifiziert: Portal-User lädt eigene Contract-Datei (200),
random Pfad (404), Traversal (400), kein Token (401), Backwards-
Compat-Shim (200).
**Bewusst NICHT gemacht (fĂĽr v1.2):**
- Signierte URLs mit kurzlebigen Download-Tokens statt JWT-im-Query
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
-Downloads ohne JS, lassen wir bis später.
- **Runde 7 – Letzter Schliff (SSRF + Logout):**
- **SSRF-Schutz** in `test-connection` und `test-mail-access`: ein
Admin-User konnte ĂĽber die Plesk-API-URL bzw. SMTP/IMAP-Server-Felder
Connections zu beliebigen IPs auslösen (Cloud-Metadata-Endpoints,
Link-Local, AWS/GCP-Metadata-Hosts). Internal-Port-Scanning via
Timing-Differenzen war messbar (22/80/3306/5432/6379 unterschiedlich).
Fix: neuer Helper `utils/ssrfGuard.ts` blockiert vor jeder ausgehenden
Verbindung 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges,
AWS-IPv6-Metadata, IPv6-Link-Local und bekannte Cloud-Metadata-
Hostnames (metadata.google.internal etc.). Loopback (127.0.0.0/8)
bleibt erlaubt fĂĽr legitime Plesk/Postfix-Setups.
- **Logout-Endpoint** `POST /api/auth/logout`: setzt
`tokenInvalidatedAt` / `portalTokenInvalidatedAt` auf jetzt. Auth-
Middleware prĂĽft das Feld und lehnt Tokens mit `iat` davor ab.
JWTs sind stateless – ohne diesen Mechanismus bleibt ein
„abgemeldeter" Token bis zum natürlichen Expiry (7d) gültig.
- Live-verifiziert: 169.254.169.254/metadata.google.internal/0.0.0.0
werden mit 400 abgelehnt; 127.0.0.1 weiter erlaubt; Logout
invalidiert den Token sofort (HTTP 401 „Sitzung ungültig").
**GeprĂĽft + sauber (Runde 7):**
- Public Consent (random Hash → 404, kein Brute-Force durch 122-bit-UUID)
- Magic-Bytes-Bypass beim Upload (HTML als image/png) → blockiert
- PDF-Generation mit injizierten manualValues → kein XSS-Vektor (PDFs sind keine Web-Renderer)
- Audit-Logs fĂĽr Portal-User: 403
- Email-Config-Update als Portal: 403
- Backup-Endpoints als Portal: 403
- Query-Filter-Override (?customerId=X) → vom Portal-Filter ignoriert
**Bewusst NICHT gefixt (zu invasiv fĂĽr v1.0):**
- Vollständige DNS-Resolution beim SSRF-Guard (gegen DNS-Rebinding) –
kann legitimes CDN/Caching brechen, v1.1-Item.
- Per-File-Ownership-Check bei `/api/uploads` (siehe Runde 5).
- **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):**
- 🚨 **`GET /api/customers` leakte als Portal-User die komplette
Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der
Single-Endpoint war Stage 4 mit `canAccessCustomer` gefixt, der List-
Endpoint nicht. Jetzt: Portal-User bekommt nur eigene + vertretene
Kunden (Filter im Controller).
- 🚨 **Rate-Limit-Bypass via `X-Forwarded-For`**: 12+ Login-Versuche
mit rotierenden XFF-Werten gingen alle durch ohne 429. `trust proxy = 1`
hat naiv jedem XFF-Wert vertraut. Jetzt: `trust proxy = 'loopback'` –
XFF wird nur akzeptiert wenn die Connection von 127.0.0.1 / ::1 kommt
(= lokaler Reverse-Proxy). Plus: `LISTEN_ADDR=127.0.0.1` in Production-
Default, damit das Backend nicht direkt von auĂźen ansprechbar ist.
- **Self-Grant + Existence-Disclosure in `toggleMyAuthorization`**:
- Portal-User konnte sich selbst Vollmacht erteilen (1→1) und
Datensätze für beliebige `representativeId`s anlegen (auch nicht-
existierende, scheiterte erst auf DB-Constraint mit Prisma-Stack-Leak).
- 404 vs 403 erlaubte Existence-Probing der gesamten customer-ID-Range.
- Fix: Self-Grant 400er. Existenz + aktives `CustomerRepresentative`-
Verhältnis in einem Query, beide Fehlfälle identisch 403.
- **Prisma-Error-Leak generisch in `toggleMyAuthorization`**: keine
Prisma-Stacks mehr im Response.
- Live-verifiziert: Customer-Liste 3 statt 3000 (jetzt nur erlaubte),
Self-Grant 400, Existence-Disclosure dicht (alle 403 uniform), Auth
auf `/api/customers/:id` 200/403 (kein 404-Leak).
**GeprĂĽft + sauber (Runde 6):**
- Prototype Pollution beim Login → kein Effekt
- HTTP-Method-Override via Header → ignoriert
- Path-Traversal in Backup-Name → durch Regex blockiert
- Developer-Routes existieren nicht (404)
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
- Self-grant Vollmacht via `customers/X/representatives` → 403 (perm)
- `/api/customers/:id` GET: 200 fĂĽr eigene, 403 sonst (kein 404-Leak)
**Offen fĂĽr v1.1:**
- `/api/contracts/:id` GET liefert 404 fĂĽr nicht-existente IDs (Existence-
Probing). Da contractIds aber nicht direkt mit personenbezogenen Daten
korrelieren, niedrig-Prio. Vereinheitlichung auf 403 wäre sauberer.
- Prisma-Error-Leaks in anderen Admin-Endpoints (z.B. `addInvoice` bei
Validation-Fehler) – Defense-in-Depth-Kandidat.
- **Runde 5 – Hack-Das-Ding-Audit (Live-Pentest + 3 parallele Audit-Agents):**
- 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) – jetzt hinter
`authenticate`. Direkte -Links nutzen `?token=...` Query-Parameter,
unterstützt von auth-Middleware. Frontend-Helper `fileUrl(path)` hängt
Token automatisch an, 24 URLs migriert (CustomerDetail, ContractDetail,
InvoicesSection, PdfTemplates, GDPRDashboard).
- **Login-Timing-Side-Channel**: Bei ungĂĽltigem User fehlte `bcrypt.compare`
→ 110ms vs 10ms, User-Enumeration trivial. Jetzt Dummy-bcrypt-compare
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
Live-verifiziert: 422ms vs 425ms – Timing-Angriff dicht.
- **XSS via Privacy Policy / Imprint**: 4 Frontend-Seiten renderten
Backend-HTML ohne DOMPurify (`PortalPrivacy`, `ConsentPage`,
`PortalWebsitePrivacy`, `PortalImprint`). Admin-eingegebene
`