ef238b0145
Pentest Runde 10: MEDIUM – Stale Token nach Vollmacht-Widerruf: Selbst ein frischer Portal-Login lieferte JWT mit representedCustomer- Ids/representedCustomers, obwohl die Vollmacht widerrufen war. Live- Check beim Datenzugriff fing das ab (403), aber die UI zeigte weiter „kann vertreten". customerLogin und getCustomerPortalUser (= /me + Refresh) filtern representingFor jetzt zusätzlich über getAuthorizedCustomerIds() – nur Beziehungen mit isGranted=true landen im Token. MEDIUM – DTO-Leak in embedded Objekten: GET /customers/:id lieferte contracts[] mit commission/notes/ portalPasswordEncrypted/nextReviewDate; embedded customer in /contracts/:id zeigte notes. sanitizeCustomer(Strict) ruft jetzt sanitizeContract(Strict) auf jedes Element von contracts[] auf; `notes` ist als PORTAL_HIDDEN_CUSTOMER_FIELDS aufgenommen. LOW – /tasks?customerId=X gibt 200 mit leerem Array statt 403: Konsistenz-Fix: wenn Portal-User explizit nach customerId filtert, die er nicht vertreten darf → 403. Live-verifiziert: - Customer 1 vertritt 2+3 (Vollmachten widerrufen) → JWT representedCustomerIds=[], /me dito - Portal /customers/1.contracts[0]: keine Leaks; Admin sieht weiter commission/notes; portalPasswordEncrypted generell weg - Portal /tasks?customerId=2 → 403; /tasks?customerId=1 → 200 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
828 lines
46 KiB
Markdown
828 lines
46 KiB
Markdown
# 📋 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 10 – Live-Vollmacht-Konsistenz + DTO-Leaks in embedded Objekten**
|
||
- **MEDIUM – Stale Token nach Vollmacht-Widerruf**:
|
||
Selbst ein FRISCHER Portal-Login lieferte JWT mit
|
||
`representedCustomerIds: [7]` und `representedCustomers: [{Nina,…}]`,
|
||
obwohl die Vollmacht widerrufen war. Live-Check beim Datenzugriff
|
||
funktionierte (403), aber die UI zeigte dem Vertreter weiter, dass
|
||
er Nina vertreten könne.
|
||
* **Fix**: `customerLogin` und `getCustomerPortalUser` (= /me +
|
||
Refresh-Pfad) filtern `representingFor` jetzt zusätzlich über
|
||
`getAuthorizedCustomerIds()` – nur Beziehungen mit
|
||
`isGranted: true` landen im Token und in /me.
|
||
* Verifiziert: Customer 1 (vertritt 2,3 aber alle Vollmachten
|
||
widerrufen) → JWT.representedCustomerIds = `[]`, /me ebenfalls.
|
||
- **MEDIUM – DTO-Leak in embedded Objekten**:
|
||
`GET /customers/:id` lieferte zwar Customer-Top-Level sanitisiert,
|
||
aber `contracts[]` darin enthielt weiterhin `commission`, `notes`,
|
||
`portalPasswordEncrypted`, `nextReviewDate`. Analog `notes` auf
|
||
embedded customer in `/contracts/:id`.
|
||
* **Fix**: `sanitizeCustomer(Strict)` ruft jetzt
|
||
`sanitizeContract(Strict)` für jedes Element in `contracts[]`
|
||
auf. `notes` zu `PORTAL_HIDDEN_CUSTOMER_FIELDS` ergänzt
|
||
(interne CRM-Vermerke).
|
||
* Verifiziert: Portal-User sieht in `customers/1.contracts[*]`
|
||
keine commission/notes/PW-Encrypted/nextReviewDate mehr;
|
||
Admin sieht sie weiterhin (Workflow-Bedarf);
|
||
`portalPasswordEncrypted` ist generell entfernt (Klartext nur
|
||
via `/contracts/:id/password` mit Audit-Log).
|
||
- **LOW – `/tasks?customerId=X` 200 statt 403 für fremde IDs**:
|
||
Konsistenz-Issue: nach Vollmacht-Widerruf gab der Endpoint
|
||
leeres Array statt einen klaren 403-Fehler. Jetzt: wenn der
|
||
Portal-User explizit nach einer customerId filtert, die er nicht
|
||
(mehr) vertreten darf → 403 mit "Kein Zugriff auf diese
|
||
Kundendaten". Verifiziert.
|
||
|
||
- [x] **🚨 Pentest Runde 7 (Anschlussrunde) – Information-Disclosure + Input-Validation**
|
||
- **MEDIUM – Interne Felder in Portal-Responses**:
|
||
* `sanitizeCustomerStrict` strippt jetzt zusätzlich
|
||
`portalTokenInvalidatedAt`, `portalLastLogin`,
|
||
`portalPasswordMustChange`, `lastBirthdayGreetingYear`,
|
||
`privacyPolicyPath`, `businessRegistrationPath`,
|
||
`commercialRegisterPath`.
|
||
* Neue `sanitizeContract` / `sanitizeContractStrict` /
|
||
`sanitizeContracts(Strict)`: entfernt
|
||
`portalPasswordEncrypted` (immer; ist nur über den dedizierten
|
||
`/password`-Endpoint mit Audit-Log abrufbar) und für Portal-
|
||
User zusätzlich `commission`, `notes`, `nextReviewDate`.
|
||
* `getContract` + `getContracts` rufen jetzt die passende
|
||
Sanitize-Variante je nach `req.user.isCustomerPortal` auf;
|
||
Mitarbeiter sehen weiterhin commission/notes (Admin-Workflow),
|
||
nur `portalPasswordEncrypted` ist generell entfernt (Klartext
|
||
nur über dedicated Endpoint).
|
||
* Live-verifiziert: Portal sieht 0 Leaks, Admin sieht
|
||
commission/notes weiterhin.
|
||
- **LOW – Integer-Truncation bei IDs**:
|
||
`parseInt('6abc')` → `6` hat alle Endpoints durchgewunken.
|
||
Neuer middleware in `index.ts`: jedes URL-Pfad-Segment unter
|
||
`/api`, das mit Ziffer beginnt aber nicht aus reinen Ziffern
|
||
besteht, wird mit HTTP 400 abgelehnt. Heuristik trifft alle
|
||
`/resource/<id>(\D+)`-Patterns ohne dass jeder einzelne
|
||
Sub-Router angefasst werden muss.
|
||
* Live-verifiziert: `/customers/6abc` → 400 mit klarer Meldung,
|
||
`/customers/3` weiterhin 200, `/contracts/1abc/history`
|
||
→ 400, normaler Pfade `/audit-logs/customer/3` → 200.
|
||
- **INFO – Login-Rate-Limit „nach 6 nicht aktiv"**:
|
||
Code-Stand `limit: 10` für `loginRateLimiter`, lokal verifiziert:
|
||
11. Versuch = 429. Pentester sah vermutlich noch alten Build
|
||
oder eine andere Lokation (PW-Reset hat `limit: 5`). Kein
|
||
Code-Change.
|
||
|
||
- [x] **🛠 Rate-Limit-Sperren: Admin-UI zum Freigeben**
|
||
- Bei einer Pentest-Runde hat der Tester sich selbst durch zu viele
|
||
Login-Versuche ausgesperrt → ohne Container-Restart kein Weg zurück.
|
||
Jetzt: Admin sieht die Sperren und kann sie einzeln aufheben.
|
||
- **Datenquelle für die Liste**: `SecurityEvent`-Tabelle filtert nach
|
||
`type = RATE_LIMIT_HIT` im 15-Min-Fenster (= Login-Window), gruppiert
|
||
nach IP. Pro Eintrag: IP, zuletzt versuchte E-Mail, Limiter-Typ
|
||
(Login / Passwort-Reset), Hit-Anzahl, Zeit seit letztem Hit.
|
||
- **Reset**: ruft `loginRateLimiter.resetKey(ip)` und
|
||
`passwordResetRateLimiter.resetKey(ip)` auf – exposiert von
|
||
`express-rate-limit` v7. Idempotent, audited.
|
||
- **Backend**:
|
||
* `GET /api/settings/rate-limits/active` (`settings:read`)
|
||
* `POST /api/settings/rate-limits/reset` (`settings:update`) mit
|
||
Body `{ ipAddress }`
|
||
* neuer Controller `rateLimitAdmin.controller.ts`
|
||
- **Frontend**: neue Seite `/settings/rate-limits` mit Tabelle +
|
||
Freigeben-Button, 15s Auto-Refresh; Kachel in Settings-Übersicht
|
||
(orange, neben „Sicherheits-Monitoring").
|
||
- **Live-verifiziert (4 Schritte)**: 11 falsche Logins von
|
||
127.0.0.1 → 11. → 429; Liste zeigt IP + Email + Hits;
|
||
POST Reset → 200; nächster Login mit falschem PW → 401 statt
|
||
429 (Sperre weg); Audit-Log enthält Eintrag.
|
||
|
||
- [x] **🚨 Pentest Runde 7 – Hit-List durchgegangen + kurzlebige Download-Tokens**
|
||
- **Credential-Endpoints** (Contracts password/internet/sip/simcard +
|
||
Stressfrei mailbox/send/reset-password): ALLE bereits durch
|
||
`canAccessContract`/`canAccessStressfreiEmail` gesichert – keine
|
||
Lücke gefunden.
|
||
- **`GET /customers/:id/portal/password`** (Klartext-Portal-Passwort-
|
||
Abruf): hatte KEINEN `canAccessCustomer`-Check. Fix: eingefügt.
|
||
Defense in depth gegen versehentlich falsch vergebene
|
||
`customers:update`-Permission.
|
||
- **Admin-Funktionen** (factory-reset, developer/*, audit-logs/rehash,
|
||
audit-logs/customer): alle durch admin-level Permissions
|
||
(`settings:update`, `developer:access`, `audit:admin`, `audit:read`)
|
||
geschützt – Portal-User haben diese nicht.
|
||
- **Token-in-URL (NIEDRIG)**: Langlebige Access-JWTs landeten als
|
||
`?token=` in URLs für PDF-iframe, Audit-Log-Export, PDF-Generate
|
||
und Portal-Privacy-PDF → nginx-Access-Logs, Browser-History,
|
||
Referer-Header.
|
||
* **Neuer Mechanismus**: `POST /api/auth/download-token` liefert
|
||
ein kurzlebiges JWT mit `type: 'download'` und `exp: 60s`.
|
||
* Auth-Middleware akzeptiert `type: 'download'` AUSSCHLIESSLICH
|
||
via `?token=` Query, niemals als Bearer-Header. So kann ein in
|
||
Logs geleaktes Download-Token nicht für reguläre API-Aufrufe
|
||
missbraucht werden.
|
||
* Frontend-Migration: 4 Stellen umgestellt (Audit-Log-Export,
|
||
PDF-Template-Preview, PDF-Generate von ContractDetail + Modal,
|
||
Portal-Privacy-PDF). `fileUrl` und `getAttachmentUrl` sind
|
||
synchron und in vielen Components verstreut – Migration dieser
|
||
bleibt als Folge-Aufgabe.
|
||
* Live-verifiziert: Download-Token = 1773 Zeichen, type=download,
|
||
exp-iat=60s, als Header → 401, als ?token= → 200.
|
||
|
||
- [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 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)
|
||
|
||
- [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)
|