Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen, um unter der LOGIN_FAILED-Schwelle zu bleiben. Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent via emit). Fix daher an zwei Ebenen: - Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert), ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert bewusst nicht (normaler Erstbesuch). - Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH (semantisch ein Refresh, kein Login). Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3133 lines
183 KiB
Markdown
3133 lines
183 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] **🛡️ Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18)
|
||
- Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos
|
||
`TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf
|
||
geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der
|
||
Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter
|
||
CRITICAL zu bleiben).
|
||
- **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zählt
|
||
`SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action →
|
||
`LOGIN_FAILED`) hätte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen:
|
||
- **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` →
|
||
greift die bestehende Schwelle `≥3 TOKEN_REJECTED/5min/IP → CRITICAL`
|
||
(securityAlert.service, kein Severity-Filter). Severity wie Access-Token:
|
||
abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungültige
|
||
Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafür
|
||
`err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert
|
||
NICHT (normaler Erstbesuch).
|
||
- **Audit-Triage:** fehlgeschlagener Refresh → Sensitivität HIGH statt LOW +
|
||
Label „Token-Refresh abgelehnt (ungültig/abgelaufen)". Action bleibt
|
||
bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login).
|
||
- Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grün.
|
||
- Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation
|
||
bietet keinen Replay-Schutz (alter Token bis exp gültig), aber fail-closed
|
||
nach Logout. Ggf. später: Refresh-Token-Jti-Blacklist / One-Time-Use.
|
||
|
||
- [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18)
|
||
- Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach
|
||
Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` /
|
||
„Anmeldung erstellt" / **CRITICAL** / User `anonymous` geloggt → sah aus wie
|
||
anonyme Login-Flut, war aber die eigene Session.
|
||
- Neuer `AuditAction`-Wert **`TOKEN_REFRESH`** (Enum-Migration
|
||
`20260818120000_audit_token_refresh_action`, idempotentes `MODIFY COLUMN`).
|
||
`determineAction()` erkennt `/auth/refresh` → eigene Action; Label
|
||
„Sitzung verlängert (Token erneuert)"; Sensitivität in der Middleware
|
||
explizit auf **LOW** (statt Default `Authentication → CRITICAL`).
|
||
- Frontend `AuditLogs.tsx`: Filter-Option „Sitzung verlängert" + dezente
|
||
Badge-Farbe (slate); Typ-Union ergänzt. `anonymous` bleibt (Endpoint läuft
|
||
ohne `authenticate`-Middleware, authentifiziert per Cookie im Service) –
|
||
bewusst nicht geändert (Option 1).
|
||
- Verifiziert: Migration auf Dev-DB aktiv, `tsc` + `vite build` grün.
|
||
|
||
- [x] **🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18)
|
||
- `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/
|
||
`carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma →
|
||
injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf
|
||
Fremdvertrag umhängen) oder den **PK frei setzen** (stilles 200 statt 400).
|
||
MEDIUM (Integrität; kein Cross-Tenant, staff-only, Portal 403).
|
||
- **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/
|
||
`pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet`
|
||
war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten
|
||
abgeglichen (minus id/contractId/verschlüsselt) – alle Diffs leer.
|
||
- Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` →
|
||
basePrice aktualisiert, ecd.id + contractId **unverändert** (kein Reparenting).
|
||
|
||
- [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18)
|
||
- `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint
|
||
`POST /tasks` (staff-only, `contracts:update`) für allgemeine Aufgaben ohne
|
||
Vertrag/Kunde. Ohne Vertrag → **kein Kunde → nie im Portal sichtbar**
|
||
(`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei
|
||
contractloser Aufgabe; getAllTasks-Portal-Filter schließt sie automatisch aus).
|
||
- Task-Modal (Mitarbeiter): Checkbox „Ohne Kunde (allgemeine Aufgabe)" – blendet
|
||
Kunden-/Vertragsauswahl **und** „Im Kundenportal sichtbar" aus. Task-Liste zeigt
|
||
solche Aufgaben als „Allgemeine Aufgabe (ohne Vertrag)" (kein Vertrags-Link).
|
||
|
||
- [x] **📧 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18)
|
||
- Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail-
|
||
Providern konfigurierten Domain (oder Subdomain) liegen → man trägt so keine
|
||
verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist
|
||
**nur beim Anlegen** Pflicht (Bestandskunden ohne E-Mail bleiben editierbar);
|
||
die Domain-Prüfung greift aber bei create UND update, falls eine gesetzt wird.
|
||
- Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service.
|
||
|
||
- [x] **⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwünscht"** (2026-08-18)
|
||
- `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`).
|
||
Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail.
|
||
|
||
- [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14)
|
||
- MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt
|
||
**zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal
|
||
(nur Lieferadresse) pflegbar.
|
||
- **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte
|
||
(ELECTRICITY→Strom, GAS→Gas) die MaLo der gewählten Lieferadresse zeigt (mit
|
||
Copy + „dort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der
|
||
Adresse.
|
||
- **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent)
|
||
**+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` → jeweilige
|
||
Lieferadresse, Strom→maloIdElectricity / Gas→maloIdGas). Verifiziert.
|
||
- **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156-
|
||
Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address-
|
||
Whitelist gefallen → die Eigentümer-Sektion speicherte seit `cb21a2c` nicht
|
||
mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle
|
||
DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus).
|
||
|
||
- [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13)
|
||
- Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen
|
||
`...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent
|
||
zur R156-Härtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/
|
||
`updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind
|
||
damit **nicht** mehr per Form-Update setzbar (Pfade nur noch über die
|
||
Upload-Endpunkte).
|
||
- **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem
|
||
unvollständigen `ContractCreateData`-Typ!) – dabei fielen 4 echte, vom Formular
|
||
gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wären:
|
||
`previousProviderId`, `previousContractNumber`, `previousCustomerNumber`,
|
||
`nextReviewDate`.
|
||
- Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte
|
||
`id`/`contractNumber`/`cancellationLetterPath` werden ignoriert.
|
||
|
||
- [x] **🗂️ Neuer Vertragsstatus „Gekündigt / bestätigt" + Kündigungs-Workflow** (2026-08-13)
|
||
- Bisheriges **„Gekündigt"** umbenannt in **„Gekündigt / Bestätigung abwarten"**
|
||
(Status `CANCELLED`) – wird jetzt automatisch gesetzt, sobald ein
|
||
**Kündigungsschreiben** (`cancellationLetterPath`) hochgeladen wird
|
||
(aus ACTIVE/PENDING/ONGOING/EXPIRED; nie DRAFT/DEACTIVATED/bereits bestätigt).
|
||
- **Neuer Status „Gekündigt / bestätigt"** (`CANCELLED_CONFIRMED`) – automatisch,
|
||
sobald ein **Kündigungsbestätigungsdatum** vorliegt (per Bestätigungsdokument,
|
||
das das Datum füllt, ODER manuell) + Vertragsende = Kündigungsdatum. Hebt auch
|
||
aus „Bestätigung abwarten" hoch.
|
||
- **Schema:** Enum-Wert `CANCELLED_CONFIRMED`, Migration
|
||
`20260813200000_contract_status_cancelled_confirmed` (idempotentes
|
||
`MODIFY COLUMN`). **Daten-Migration:** bestehende `CANCELLED` (unter alter
|
||
Logik nur bei vorhandener Bestätigung gesetzt) → `CANCELLED_CONFIRMED`.
|
||
- **Cockpit-Semantik mitgewandert:** Fristen-Skip + „beendet" (Schlussrechnung)
|
||
gelten jetzt für `CANCELLED_CONFIRMED` (nicht mehr das reine „abwarten");
|
||
„Kündigungsbestätigung fehlt"-Warnung greift dadurch weiter für CANCELLED.
|
||
`CANCELLED_CONFIRMED` in Ladeliste + Kündigungsbestätigungs-Filter aufgenommen.
|
||
- **Frontend:** Labels/Farben/Status-Erklärungen + Status-Dropdown in
|
||
ContractList, ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
|
||
(CANCELLED = orange „abwarten", CANCELLED_CONFIRMED = rot). Verifiziert:
|
||
Schreiben→CANCELLED, Bestätigung→CANCELLED_CONFIRMED+Enddatum.
|
||
|
||
- [x] **🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13)
|
||
- Controller reichten rohen `req.body` an Prisma durch → `customerId` (Owner)
|
||
und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber
|
||
Integritätsschwäche – und mit `cardNumber` liegt Finanz-PII drauf).
|
||
- **Fix:** explizite Feld-Whitelist im Service (create+update) für **BankCard,
|
||
Address, IdentityDocument** – nur benannte Felder gehen an Prisma, kein
|
||
`...data`/`req.body`-Spread mehr. Zusätzlich Controller-`pickBankCardFields`
|
||
für saubere Audit-Logs (keine Phantom-Einträge injizierter Keys).
|
||
- Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}`
|
||
→ id+customerId **unverändert**, nur cardNumber gesetzt, Fremdfelder ignoriert.
|
||
|
||
- [x] **🪪 Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13)
|
||
- **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration
|
||
`20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev
|
||
angewandt + `prisma generate`. Prod zieht via `migrate deploy` im Entrypoint.
|
||
Eingabefeld „Kartennummer" im Bankkarten-Modal (Kundenakte) ergänzt.
|
||
- **Vertragsansicht (ContractDetail)** – Karten „Bankkarte"/„Ausweis" zeigen
|
||
zusätzlich (jeweils mit Copy-Button, nur wenn gesetzt):
|
||
- Bankkarte: BIC, Bank, Kartennummer, Ablaufdatum
|
||
- Ausweis: Behörde, Ausstellung, Ablaufdatum + **Geburtsort/Geburtsdatum
|
||
vom Kunden**
|
||
- **Vertrag bearbeiten (ContractForm)** – unter den Bankkarte-/Ausweis-
|
||
Dropdowns dieselben Detailfelder der aktuell gewählten Karte/Ausweis
|
||
(Copy-Buttons). Selects dafür je in eigenem `<div>` gewrappt (Grid-Alignment).
|
||
- Datenquelle war bereits vorhanden: `getContractById` (bankCard/identityDocument/
|
||
customer) + `getCustomerById` (bankCards/identityDocuments) liefern alle Felder.
|
||
|
||
- [x] **📄 PDF-Viewer-Modal für Bankkarte-/Ausweis-Dokument** (2026-08-13)
|
||
- Neue wiederverwendbare Komponente `PdfViewerModal` (Modal + `<iframe>` auf
|
||
`viewUrl(documentPath)`, inkl. „In neuem Tab" + „Herunterladen"). Nutzt den
|
||
bestehenden `/api/files/download`-Endpoint (Per-File-Ownership-Check) – kein
|
||
neuer Zugriffspfad. `<iframe>` erlaubt via CSP `default-src 'self'`
|
||
(`object-src 'none'` bleibt, daher iframe statt embed/object).
|
||
- **Vertragsansicht** (ContractDetail): Button (FileText-Icon) im Card-Header
|
||
von „Bankkarte"/„Ausweis" – **nur wenn `documentPath` gesetzt**.
|
||
- **Vertrag bearbeiten** (ContractForm): Button neben dem Label „Bankkarte"/
|
||
„Ausweis" – **nur wenn die aktuell gewählte Karte/Ausweis ein `documentPath`
|
||
hat** (`e.preventDefault/stopPropagation`, da im `<label>`).
|
||
|
||
- [x] **📇 Vertragslisten: Karteninhaber + Mobilfunknetz + Kündigung (rot)** (2026-08-13)
|
||
- In **beiden** Vertragslisten (Kundenakte-Baum + Hauptmenü `/contracts`): bei
|
||
Mobilfunkverträgen mit Rufnummer zusätzlich **Karteninhaber** (`SimCard.cardUser`
|
||
der angezeigten SIM, nur wenn gesetzt) und **Netz** (`mobileNetwork` →
|
||
Telekom/Vodafone/Telefónica (o2)) inline hinter der Rufnummer.
|
||
- Verträge mit **Kündigungsbestätigung**: `cancellationConfirmationDate` als
|
||
eigene Zeile in **roter, fetter Schrift** („Kündigungsbestätigung zum …").
|
||
- Backend: `getContractTreeForCustomer` + `getAllContracts` liefern jetzt
|
||
`mobileNetwork` + `cardUser` (Tree zusätzlich `cancellationConfirmationDate`;
|
||
Flat-Liste hat Scalars ohnehin via include). Shared-Helper
|
||
`getContractTypeInfo` um `cardUser`/`network` erweitert (+ `mobileNetworkLabel`).
|
||
- **3 Render-Pfade** ergänzt (waren getrennt!): Kundenakte-Baum, die
|
||
Staff-**Tabelle** in `/contracts` (flache `getAll`-Liste) UND der
|
||
Portal-Baum in `/contracts`. Der erste Anlauf hatte nur die Baum-Pfade –
|
||
die Staff-Tabelle (Haupt-Ansicht für Mitarbeiter) fehlte und wurde nachgezogen.
|
||
- **Vertragssuche** (`?search=`) durchsucht jetzt auch den **Karteninhaber**
|
||
(`simCards.cardUser`) – zusätzlich zu Rufnummer/SIM-Nummer/IMEI (Pentest-R150-
|
||
Randnotiz, fachlich gewünscht). Portal-Suche bleibt durch Scoping begrenzt.
|
||
|
||
- [x] **🛡️ Globaler API-Rate-Limit-Backstop + BLZ-Guard-Härtung (Pentest R148)** (2026-08-12)
|
||
- **Backstop:** Neuer genereller Limiter auf ALLE `/api`-Requests
|
||
(`apiBackstopRateLimiter`, in `index.ts` vor den Routern). Vorher gab es
|
||
KEINEN generellen Limiter – nur Login/Passwort-Reset/Staff-ReAuth/Consent;
|
||
authentifizierte Endpoints waren gegen Enumeration/DoS ungedrosselt.
|
||
Key = **nur IPv6-/56-normalisierte IP** (bewusst nicht IP+User: der
|
||
User-Claim wäre hier nur unverifiziert lesbar → Bypass per Fake-Token).
|
||
Limit per Env `API_RATE_LIMIT_PER_MIN` (Default **1200/min**, Floor 60),
|
||
`/api/health` ausgenommen. Kein SecurityEvent pro Block (sonst Flood-
|
||
Amplification). Verifiziert (60×200→429, health bleibt 200).
|
||
- **Deckt den offenen IPv6-Test mit ab:** außerhalb der Auth-Pfade greift jetzt
|
||
ebenfalls ein IPv6-normalisierter Limiter.
|
||
- **BLZ-Poisoning-Guard:** prüft jetzt JEDEN Dataset-Eintrag (nicht nur die
|
||
erste Zeile) – 8-stellige BLZ, Wert `[Name]` oder `[Name,BIC]`,
|
||
`next.remove` = BLZ-Liste, `next.valid` = gültiges Datum. Banken **ohne BIC**
|
||
(`[Name]`, z.B. BLZ 60050009) korrekt zugelassen → `lookupBlz` liefert `bic:''`.
|
||
|
||
- [x] **🔄 BLZ-/Bankdaten: Auto-Update via Volume + Einstellungen** (2026-08-12)
|
||
- Neue Einstellungen-Seite **Einstellungen → Bankdaten (BLZ)** (`/settings/bank-data`):
|
||
zeigt Datenstand (aktive Quelle Volume/Image, Version, Anzahl Banken,
|
||
zuletzt geprüft/aktualisiert, neueste verfügbare Version + „Update
|
||
verfügbar"-Badge, letzter Fehler). Button **„Jetzt prüfen & aktualisieren"**
|
||
(+ „Neu laden erzwingen"). Auto-Update-Schalter + **Intervall in Tagen** (1–365).
|
||
- **Echtes Laufzeit-Update** statt Rebuild: Ein Scheduler (täglich 03:30 +
|
||
Catch-up 90s nach Start) prüft, ob laut Intervall fällig, und lädt dann den
|
||
aktuellen Datensatz. Quelle = npm/jsDelivr (`bankdata-germany` current.json +
|
||
next.json), gespeichert im **Bind-Mount-Volume** `BANKDATA_DIR`
|
||
(`./data/bankdata` → `/app/bankdata`, neu in docker-compose + Dockerfile mkdir
|
||
+ .env.example).
|
||
- **Lookup-Vorrang:** Volume-Datensatz (aktuell) vor ins Image gebackenen Daten
|
||
(`bankdata-germany`, Fallback). Kombiniert current+next-Delta wie das Paket.
|
||
Kein Fremdcode – nur JSON wird gelesen. Validierung (≥1000 Einträge, Format)
|
||
verhindert, dass Müll/HTML den guten Datenstand überschreibt (atomarer
|
||
Write via tmp+rename).
|
||
- **Datenschutz:** Der Updater sendet KEINE Kundendaten – lädt nur eine
|
||
öffentliche Datendatei. Beim IBAN-Lookup verlässt weiterhin nichts den Server.
|
||
Auto-Update ist abschaltbar; ohne Egress/bei Fehler greift der Builtin-Stand.
|
||
- Endpoints: `GET /api/settings/blz` (settings:read), `POST /api/settings/blz/update-now`
|
||
(settings:update, `?force=1`). Settings-Keys `blzAutoUpdateEnabled` (default true),
|
||
`blzUpdateIntervalDays` (default 30) in Whitelist + validiert.
|
||
- **Damit entfällt die manuelle `npm update`-Wartung** aus dem vorherigen Punkt.
|
||
|
||
- [x] **🏦 Bankkarte-Modal: IBAN-Prüfung + BIC/Bank-Auto-Ausfüllen** (2026-08-12)
|
||
- Button „BIC & Bank aus IBAN abrufen" unter dem IBAN-Feld (Bankkarte
|
||
hinzufügen/bearbeiten). Füllt BIC + Banknamen automatisch aus und prüft
|
||
dabei die **IBAN-Prüfziffer (mod-97)** → erkennt Tippfehler.
|
||
- **Offline / datenschutzfreundlich:** kein Dritt-Dienst. Nachschlag im
|
||
eigenen Backend über die **Bundesbank-Bankleitzahlendatei**
|
||
(`bankdata-germany`) + `ibantools` für die Prüfziffer. Die IBAN verlässt
|
||
nie den Server; zurück kommen nur öffentliche Bankverzeichnis-Daten.
|
||
- Endpoint `POST /api/bank-cards/iban-lookup` (nur eingeloggt). Antwort:
|
||
`{valid:false}` (Prüfziffer falsch) · `{valid:true, bic, bankName, blz}`
|
||
(DE-Bank gefunden) · `{valid:true, bic:null,…}` (gültig, aber Nicht-DE/
|
||
unbekannte BLZ → Info-Toast).
|
||
- Leeres IBAN-Feld → **OK-Messagebox** „IBAN erforderlich" statt Anfrage.
|
||
- **Wartung:** `bankdata-germany` + `ibantools` müssen ~quartalsweise per
|
||
`npm update` aktualisiert werden (Bundesbank-BLZ ändert sich mehrmals/Jahr).
|
||
|
||
- [x] **📌 Update-Banner klebt oben fest (sticky)** (2026-08-12)
|
||
- „Neue Version verfügbar"-Banner scrollte weg; jetzt `sticky` an der
|
||
Viewport-Oberkante. Banner meldet seine Höhe als CSS-Var `--app-banner-h`,
|
||
an der die Sticky-Header von ContractDetail/ContractForm einrasten
|
||
(`top-[var(--app-banner-h,0px)]`) → keine Überdeckung.
|
||
|
||
- [x] **📋 Hauptmenü: Gutschriften/Lieferscheine-Gesamtübersicht** (2026-08-12)
|
||
- Neuer Menüpunkt „Gutschriften" (Sidebar, `show: contracts:read`) → Seite
|
||
`/credit-notes` mit Tabelle aller Belege (Beleg-Nr., Art, Kunde, Vertrag,
|
||
Betrag, Datum, PDF), Suche + Pagination.
|
||
- **Scoping:** neuer Endpoint `GET /credit-notes` (NICHT staff-only wie die
|
||
übrigen Credit-Note-Endpoints): Staff sieht **alle** Belege aller Kunden,
|
||
Portal-Kunden **nur eigene + vertretene** (Vollmacht via `hasAuthorization`).
|
||
`customerIds` kommt aus dem JWT (nicht aus Query/Body → nicht manipulierbar).
|
||
Für Portal wird `receiptPath` aus der Response entfernt (Belege bleiben
|
||
staff-only). Verifiziert (Staff alle, Portal nur eigene).
|
||
- **Fail-closed (Pentest R144):** Ein Portal-Token wird jetzt IMMER gescoped;
|
||
fehlt wider Erwarten die `customerId` (null/0), ergibt das eine **leere
|
||
Menge** statt des Staff-Zweigs. Verifiziert (`customerIds=[]` → 0 Belege).
|
||
- [x] **🚫 Gutschrift nur mit Empfängeradresse (Rechnung > Liefer)** (2026-08-12)
|
||
- Beim Klick auf „Gutschrift anlegen" wird geprüft, ob der Vertrag eine
|
||
Empfängeradresse hat: **Rechnungsadresse hat Vorrang, sonst Lieferadresse**.
|
||
Ist **keine** von beiden hinterlegt → **Modal-OK-Meldung** statt Formular.
|
||
- Backend Defense-in-Depth: `createCreditNote` wirft **400**, wenn weder
|
||
`billingAddressId` noch `addressId` gesetzt ist. `getCreditNoteDefaults`
|
||
liefert `hasRecipientAddress` fürs Frontend. Verifiziert.
|
||
- [x] **🧾 Gutschrift: Beleg-Upload auch für Sachwerte + kein Unterschriftsblock bei Geld** (2026-08-12)
|
||
- **Beleg-Upload jetzt für beide Arten:** bei Geld die Überweisungsbestätigung,
|
||
bei Sachwert das **unterschriebene Dokument**. `ReceiptControls` in der Liste
|
||
für Geld UND Sachwert (Label „Überweisungsbeleg" bzw. „Unterschr. Dokument").
|
||
Endpoint `/upload/credit-notes/:id/receipt` war schon typ-agnostisch.
|
||
- **PDF-Unterschriftsblock nur noch bei Sachwert:** eine Geld-Überweisung wird
|
||
nicht unterschrieben (Beleg = hochgeladene Überweisungsbestätigung). Bei Geld
|
||
entfällt der Unterschrift/Ort-Block im PDF; im Formular sind „Ort" +
|
||
„Unterschrift am" bei Geld ausgeblendet. Verifiziert (beide PDFs 1 Seite).
|
||
- [x] **📦 Lieferschein-Nummernkreis für betragslose Sachwerte (GoBD)** (2026-08-12)
|
||
- Pentester-Hinweis R142: Beim Übergang Geld→betragsloser Sachwert wurde die
|
||
schon vergebene Gutschriftsnummer auf null gesetzt → **Lücke** in der GS-Serie.
|
||
- Lösung (User): betragsloser Sachwert = **Lieferschein** mit **eigener
|
||
Lieferscheinnummer** aus **separatem Nummernkreis** (`DeliveryNoteNumberRange`,
|
||
Default-Präfix „LS-"). `CreditNote.deliveryNoteNumber` (nullbar, unique) +
|
||
Migration.
|
||
- **Gap-frei:** Nummern werden lazy pro Serie vergeben und **nie wieder
|
||
freigegeben**. Übergänge (Geld↔betragsloser Sachwert) behalten die jeweils
|
||
schon vergebene Nummer der anderen Serie reserviert am Beleg → kein
|
||
Doppelverbrauch, keine Lücke. Angezeigt/erzeugt wird via `effectiveNumber`
|
||
je nach aktuellem Typ die passende (LS bzw. GS).
|
||
- Settings-Seite verwaltet jetzt **beide** Nummernkreise (Gutschrift +
|
||
Lieferschein); neue Endpunkte `GET/PUT /credit-notes/delivery-note-number-
|
||
range`. PDF-Titel „Sachwert-Übergabe", Dateiname `lieferschein-…`.
|
||
- Verifiziert: Sachwert 0 → LS-Nr, GS-Zähler unberührt; Geld → GS-Nr;
|
||
Übergänge behalten Nummern (kein Neuverbrauch).
|
||
- [x] **🔐 Portal-Passwort: Reveal/Send prüft Konsistenz (kein „falsches" PW)** (2026-08-11)
|
||
- Pentester-Hinweis: Bei einem Kunden waren bcrypt-Hash (Login) und
|
||
verschlüsseltes Reveal-Feld out-of-sync → Support hätte ein Passwort
|
||
vorgelesen, das beim Login scheitert.
|
||
- **Analyse:** Alle aktuellen Schreibpfade sind konsistent (beide Felder
|
||
zusammen ODER encrypted=null ODER Rehash desselben Passworts) – der aktuelle
|
||
Code erzeugt keinen Desync. Ursache war Altlast/manueller DB-Eingriff.
|
||
- **Fix (Belt-and-Suspenders):** `getCustomerPortalPassword` liefert jetzt
|
||
`{status: ok|none|desync}` und prüft den entschlüsselten Klartext per
|
||
`bcrypt.compare` gegen den Login-Hash. Bei `desync` (oder Entschlüsselungs-
|
||
fehler) geben **weder Reveal noch Send-Credentials** das Passwort aus,
|
||
sondern 409 „Dateninkonsistenz – bitte neu setzen". Reveal-Read wird mit
|
||
Status auditiert.
|
||
- **Diagnose-Script** `scripts/check-portal-password-sync.ts`: scannt alle
|
||
Portal-Kunden auf Hash-vs-Encrypted-Desync (nur Diagnose) – für Prod, da
|
||
der Pentester kein FS-Zugriff hat. Verifiziert (desync/ok/none-Fälle).
|
||
- [x] **🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung** (2026-08-09)
|
||
- Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0):
|
||
dann findet **keine Rechnungsstellung** statt – der Kunde hat den
|
||
Gegenstand einfach als Subvention erhalten. Beträge bleiben 0, keine USt.
|
||
- Backend: `amount` leer → 0; Sachwert erlaubt 0, **Geld verlangt Betrag > 0**
|
||
(400 sonst). `vatRelevant` wird bei 0 erzwungen-false.
|
||
- PDF: bei betragslosem Sachwert Titel „Sachwert-Übergabe", kein
|
||
Betrags-/USt-Block (Hinweis „keine Rechnungsstellung") und **kein ZUGFeRD-
|
||
Embedding** (es ist keine Rechnung). Mit Betrag → unverändert ZUGFeRD.
|
||
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet solange
|
||
kein Betrag (Hinweis); Liste zeigt „Sachwert ohne Betrag (keine Rechnung)".
|
||
- Verifiziert (Sachwert 0 → kein factur-x.xml; Geld 0 → 400; Sachwert 200 →
|
||
ZUGFeRD).
|
||
- **Keine Gutschriftsnummer für betragslose Sachwerte:** `number` ist jetzt
|
||
nullbar (Migration `MODIFY … NULL`, UNIQUE bleibt); ein betragsloser
|
||
Sachwert (Lieferschein) bekommt **keine** Nummer und verbraucht den
|
||
Nummernkreis-Zähler **nicht**. Update-Übergänge: wird betragslos → Nummer
|
||
entfernt; bekommt nachträglich einen Betrag → jetzt Nummer vergeben. PDF/
|
||
Liste/Audit zeigen dann „Sachwert-Übergabe"/„Beleg #id". Verifiziert
|
||
(Zähler bleibt bei Sachwert 0 stehen, springt nur bei echten Gutschriften).
|
||
|
||
- [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07)
|
||
- **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit
|
||
`previousContractId` an – der Verlust passierte im Frontend: das
|
||
„Vorgänger-Vertrag"-Dropdown holte die Verträge über `getAll`, das
|
||
**DEACTIVATED standardmäßig ausblendet**. Beim Bearbeiten des Folgevertrags
|
||
war der (deaktivierte) Vorgänger daher nicht als Option vorhanden → beim
|
||
Speichern ging die Verknüpfung verloren. Fix: `getAllContracts` +
|
||
Controller + `contractApi.getAll` um `includeDeactivated` erweitert; das
|
||
Vorgänger-Dropdown nutzt es (`includeDeactivated: true`) und markiert
|
||
deaktivierte Verträge mit „· deaktiviert". Verifiziert.
|
||
- **Kundendaten-Modal (Schnellansicht):** zeigt jetzt zusätzlich die
|
||
**Lieferadresse** und (falls abweichend) **Rechnungsadresse** des geöffneten
|
||
Vertrags, die **Stressfrei-Adresse des Vertrags** (einzeln) und darunter
|
||
**alle weiteren Stressfrei-Adressen** des Kunden. `CustomerInfoModal` nimmt
|
||
optionale Props (deliveryAddress/billingAddress/contractStressfreiEmailId),
|
||
Aufrufe aus ContractDetail + ContractForm übergeben den Vertragskontext.
|
||
|
||
- [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07)
|
||
- **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine
|
||
Kündigungsbestätigung hinzugefügt – als **Datum** (`cancellationConfirmationDate`
|
||
übers Formular) und/oder als **Dokument** (Upload) – wird der Vertrag von
|
||
ACTIVE → **CANCELLED** gesetzt und das **Vertragsende = Kündigungsdatum**.
|
||
Zentrale Funktion `maybeCancelOnCancellationConfirmation` (idempotent, nur
|
||
aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte
|
||
Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei
|
||
neu/geändertem Bestätigungsdatum, damit manuelle Status-Korrekturen nicht
|
||
zurückgesetzt werden). Verifiziert.
|
||
- **2) Cockpit-Filter „Kündigungsbestätigung":** listet alle Verträge mit
|
||
hinterlegter Kündigungsbestätigung (Dokument und/oder Datum) in Status
|
||
ACTIVE/DRAFT/CANCELLED/EXPIRED (Abgelaufen nachträglich ergänzt). Eigene Liste `cancellationConfirmations` im
|
||
Cockpit-Result (weil bereits CANCELLED-Verträge mangels „Issue" sonst nicht
|
||
auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert.
|
||
- **Nachbesserungen Pentest R138:**
|
||
- `endDate` wird bei **DRAFT** nicht mehr gesetzt (Entwurf = Vorlage);
|
||
nur ACTIVE/CANCELLED bekommen Vertragsende = Kündigungsdatum.
|
||
- `cancellationConfirmationDate`/`-OptionsDate` im Update-Pfad über
|
||
`validateOptionalIsoDate` normalisiert → akzeptiert Datum-only UND
|
||
volles ISO (vorher lehnte Prisma Datum-only ab), konsistent zum Upload.
|
||
- **Gutschrift-Dateien:** Überweisungsbelege (`credit-note-receipts`) sind
|
||
jetzt **staff-only** (neuer FileOwner-Typ `contract-staff`, Portal 403);
|
||
das Gutschrift-**PDF** (`credit-notes`) bleibt vertragsbasiert (eigener
|
||
Kunde darf seine Gutschrift laden). Anlegen/Beleg-Upload für Portal war
|
||
bereits per `blockPortal`/Portal-403 gesperrt.
|
||
- **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden
|
||
PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird
|
||
geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr.
|
||
- **Hygiene R140:** Datei-Lösch-Helfer in `utils/fileCleanup.ts`
|
||
konsolidiert (`deleteFileAbsolute` + `deleteUploadByRelativePath`),
|
||
ersetzt die vorher 3× kopierten `deleteFileIfExists`/`cleanupFile`
|
||
(creditNote-, upload-, customer-Service). Reihenfolge umgestellt:
|
||
erst DB-Delete/-Update, DANN Datei löschen (schlägt DB fehl, bleibt
|
||
die Datei). Verifiziert.
|
||
|
||
- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06)
|
||
- Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld**
|
||
(Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld).
|
||
Betrag, Datum (änderbar), Ort/Unterschrift, bei Sachwert „Ware erhalten",
|
||
bei Geld Überweisungsbeleg (Upload, Phase 2).
|
||
- **USt pro Gutschrift wählbar** (`vatRelevant` + Basis NETTO/BRUTTO +
|
||
`vatRate`) → Netto/USt/Brutto werden berechnet & getrennt gespeichert
|
||
(ZUGFeRD-tauglich). `PRIVAT`/`FIRMA` aus Kunde vorbelegt. Verifiziert:
|
||
nicht-USt 200→200/0/200; NETTO 200→200/38/238; BRUTTO 200→168,07/31,93/200.
|
||
- **Nummernkreis** (`CreditNoteNumberRange`, Einzel-Zeile, in Settings
|
||
verwaltbar): Präfix/Jahr/Separator/Padding/Startzähler/Jahres-Reset.
|
||
Nummernvergabe transaktional mit `SELECT … FOR UPDATE` (keine Doppel-
|
||
vergabe). Beispiel `GS-2026-0001`.
|
||
- Neu: Model `CreditNote` + `CreditNoteNumberRange` + Enums + Migration
|
||
(IF NOT EXISTS, auf Dev angewandt). Service/Controller/Routes:
|
||
`GET/POST /contracts/:id/credit-notes`, `GET .../defaults`,
|
||
`GET/PUT/DELETE /credit-notes/:id`, `GET/PUT /credit-notes/number-range`.
|
||
Portal-Token explizit geblockt (interner Bereich), CREATE/UPDATE/DELETE
|
||
auditiert.
|
||
- **Phase 2a (erledigt):** Kunden-Feld `vatExempt` (Kleinunternehmer §19,
|
||
Checkbox nur für Firmenkunden) + Migration. Gutschrift-Default `vatRelevant`
|
||
wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst
|
||
aus. Jede Gutschrift speichert ihren Snapshot → späterer Statuswechsel
|
||
ändert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten".
|
||
- **Phase 2b (erledigt):** `CreditNotesSection` im Vertragsdetail (eigene
|
||
Section wie Rechnungen – die Vertragsansicht ist kartenbasiert, kein
|
||
echter Tab-Balken; ggf. später umbaubar): Liste + Anlegen/Bearbeiten
|
||
(Modal, Geld/Sachwert, USt-Block mit Live-Netto/USt/Brutto, Datum, Ort,
|
||
Unterschrift, „Ware erhalten"), Überweisungsbeleg-Upload (nur GELD, über
|
||
`/upload/credit-notes/:id/receipt`, fileDownload-Ownership ergänzt).
|
||
Nummernkreis-Verwaltung als Settings-Seite `/settings/credit-note-number-
|
||
range` mit Live-Vorschau. Frontend-Typen + `creditNoteApi`.
|
||
- **Phase 3a (erledigt):** `CompanyProfile` (Firmenstammdaten/Absender) +
|
||
Migration + Settings-Seite `/settings/company-profile`.
|
||
- **Phase 3b Teil 1 (erledigt):** Gutschrift-**PDF** (pdfkit) mit Absender
|
||
aus CompanyProfile, Empfänger, Netto/USt/Brutto, Sachwert/„Ware erhalten"
|
||
bzw. Überweisungs-Bankverbindung, Unterschriftsblock, Fußzeile. Endpoint
|
||
`POST /credit-notes/:id/pdf`, „PDF"-Button in der Section (Ansehen/Erzeugen).
|
||
PDF-Pfad wird bei inhaltlicher Änderung geleert (Neu-Erzeugung). Lokal
|
||
verifiziert (valides PDF).
|
||
- **Auszahlungskonto (nur GELD):** `CreditNote.payoutBankCardId` +
|
||
Migration (FK ON DELETE SET NULL). Dropdown im Formular mit ALLEN
|
||
Bankkonten des Kunden (Default = Vertrags-Abbuchkonto), da die Auszahlung
|
||
auf ein anderes Konto gehen kann. Server prüft, dass die Bankkarte dem
|
||
Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere
|
||
Bankverbindung" + „an Bankkonto: <Kunden-IBAN>". Section-Zeile zeigt das
|
||
Auszahlungskonto.
|
||
- **Phase 3b Teil 2 (erledigt):** ZUGFeRD/Factur-X. `zugferd.service.ts`
|
||
erzeugt CII-XML (EN 16931 `urn:cen.eu:en16931:2017`, Typ **381**;
|
||
Kategorie S bei USt, sonst E + Befreiungsgrund). `zugferdPdf.service.ts`
|
||
bettet als hybrides **PDF/A-3B** ein: `factur-x.xml` (AF /Data), sRGB-
|
||
OutputIntent (pdfkit-ICC ins Repo kopiert), XMP (pdfaid part=3/conf=B +
|
||
Factur-X-Extension-Schema). PDF nutzt **eingebettete DejaVuSans-Fonts**
|
||
(im Repo unter `backend/assets/fonts`, Pflicht für PDF/A). Dockerfile
|
||
kopiert `backend/assets` ins Runtime-Image. Lokal strukturell verifiziert
|
||
(1 Seite, /AF, /Metadata, /OutputIntents, EmbeddedFiles, Font eingebettet,
|
||
XML wohlgeformt, TypeCode 381, GrandTotal korrekt).
|
||
- **⚠️ VOR PROD:** hybrides PDF gegen einen **ZUGFeRD-/Factur-X-Validator**
|
||
prüfen (am besten auf Staging mit echten Firmendaten). Feinheiten
|
||
(Trailer-ID, XMP-Details, MIME `text/xml` vs `application/xml`) ggf.
|
||
nach dem ersten Validator-Lauf nachziehen.
|
||
- **Offen (fachlich):** USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
|
||
Steuerberater klären – Modell/XML decken beide über `vatRelevant` ab.
|
||
ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen.
|
||
|
||
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
|
||
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
|
||
Neu-Formular unter `/contracts/new?copyFrom=<id>&customerId=<cid>` mit
|
||
ALLEN Daten des Quellvertrags vorbefüllt. Beim Speichern entsteht ein
|
||
frischer, unabhängiger Vertrag (kein `previousContractId`-Link, kein VVL).
|
||
- Use-Case: neuer Mobilfunk-/o.ä. Vertrag ist meist fast identisch – nur
|
||
Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif ändern sich.
|
||
- Geleert im Kopier-Modus (Integrität / Eindeutigkeit): Status→DRAFT,
|
||
Vorgänger-Link, alle Datumsfelder (Start/Ende/Installation/Kündigungs-
|
||
bestätigung), Kunden-/Vertragsnummer beim Anbieter + Plattform-Nummern.
|
||
**Nicht** übernommen: SIM-Karten & Rufnummern (hardware-/vertrags-
|
||
spezifisch, eindeutige Nummern) – inkl. der Legacy-Scalar-Felder
|
||
`phoneNumber`/`simCardNumber` (Pentest R135/136: die wurden zunächst
|
||
noch kopiert → zwei getrennte Verträge hätten dieselbe Rufnummer tragen
|
||
können; jetzt im Leer-Block). **Behalten** wird der Portal-Zugang
|
||
(Benutzername + Stressfrei-Verknüpfung/„nicht benötigt") – bei gleichem
|
||
Anbieter oft identisch; nur das verschlüsselte Passwort wird nicht ins
|
||
Feld geladen (wie beim Bearbeiten). Rest (Anbieter/Tarif, Preise, alle
|
||
Detailfelder, Notizen) bleibt als Vorlage.
|
||
- Rein Frontend (`ContractForm` copy-Mode + Button in `ContractDetail`);
|
||
kein neuer Endpoint – nutzt bestehendes Create. Blauer Hinweis-Banner
|
||
„Kopie … neuer eigenständiger Vertrag" im Formular.
|
||
|
||
- [~] **🖱️ PDF-Drag-and-Drop aus Bankkarte/Ausweis — VERWORFEN** (2026-07-30)
|
||
- Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer
|
||
ziehen. Über mehrere Iterationen durchgespielt (Chromium-`DownloadURL`
|
||
mit Access-Token → 60s-Download-Token → Blob/`items.add(File)`).
|
||
- **Ergebnis: harte Plattformgrenze.** Ein Browser kann einer fremden
|
||
Desktop-App keine echte lokale Datei zum Anhängen übergeben. URL-Drag →
|
||
Thunderbird speichert nur einen Link (0 Bytes, lädt beim Senden nach →
|
||
Fehler). Datei-Drop in den Nachrichtentext → nur Dateiname als Text.
|
||
Auf die Anhang-Leiste gezogen (Thunderbird/Linux/Chromium) → ebenfalls
|
||
kein echter Anhang. Webmail im Browser → gar keine Datei möglich.
|
||
- **Entscheidung (User):** komplett wieder entfernt. Download- + Anzeigen-
|
||
Button decken den Bedarf zuverlässig ab. Komponente `PdfDragButton`
|
||
gelöscht, `fileUrl()`-Token-Param zurückgebaut. Pentest R131 damit
|
||
gegenstandslos (kein Drag-Feature mehr).
|
||
- **Bleibt erhalten:** die separaten Copy-Buttons für IBAN (Bankkarte) und
|
||
Ausweisnummer im Vertragsformular (s.u.).
|
||
|
||
- [x] **📋 Vertragsformular: Copy-Buttons für Bankkarte (IBAN) + Ausweis** (2026-07-30)
|
||
- In „Kundendaten verknüpfen" je ein Kopieren-Button neben dem Label,
|
||
kopiert nur den reinen Wert der aktuell gewählten Option: bei Bankkarte
|
||
die **IBAN ohne Namen**, bei Ausweis die **Ausweisnummer ohne (TYP)**.
|
||
- Button erscheint nur, wenn eine Karte/ein Ausweis ausgewählt ist
|
||
(`watch('bankCardId')`/`watch('identityDocumentId')` → Lookup).
|
||
|
||
- [x] **📧 Stressfrei-Mailbox: Passwort beim Anlegen verbindlich setzen** (2026-07-30)
|
||
- Beim Anlegen einer Adresse mit „Echte Mailbox erstellen (IMAP/SMTP)"
|
||
wird jetzt immer ein frisch generiertes Passwort **explizit am Provider
|
||
gesetzt** (`updateMailboxPassword` direkt nach dem Provisioning).
|
||
- Behebt den Fall, dass die Adresse beim Provider bereits als reine
|
||
Weiterleitung existierte: dann kehrte `provisionEmailWithMailbox` früh
|
||
mit success zurück, ohne je ein Postfach-Passwort zu setzen – im CRM lag
|
||
ein verschlüsseltes Passwort, das der Provider nicht kannte → IMAP/SMTP-
|
||
Login schlug fehl. Jetzt stimmen CRM und Provider garantiert überein.
|
||
- **Nachtrag Pentest R130:** Erster Versuch (`updateMailboxPassword`)
|
||
setzte bei Plesk nur `-passwd`, ließ die Adresse aber auf
|
||
`-mailbox false` → Mailbox nie aktiviert, Login scheiterte weiter.
|
||
Jetzt `enableMailboxForExistingEmail` (`-mailbox true -passwd ...`),
|
||
deckt beide Fälle idempotent ab.
|
||
- Tab-Leiste bei vielen Tabs: `flex-wrap` statt Overflow aus dem Rahmen.
|
||
- Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag (s.u.).
|
||
|
||
- [x] **🤝 Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)**
|
||
- Neuer Tab vor „Datenschutz", **nur Mitarbeiter/Admin** (nicht Portal),
|
||
**ohne** Consent-Pflicht nutzbar. Zwei Abschnitte:
|
||
1. „<Kunde> wurde an Board geholt durch:" – **max. 1** Werber
|
||
(DB-Unique auf `recruitedId`).
|
||
2. „<Kunde> hat folgende Kunden an Board geholt:" – beliebig viele.
|
||
- Jede Zeile: Kunde (per Lupe-Modal gesucht, breite Suche über
|
||
Name/Nr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown (Ehepartner/in,
|
||
Kind, Freund/Kumpel, … „Bitte auswählen!"). Löschen + Externtab-Link
|
||
zur Kundenakte pro Zeile.
|
||
- **Bidirektional aus einem Datensatz:** trägt man in Kunde A „geworben
|
||
durch B" ein, erscheint A automatisch bei B unter „hat geworben".
|
||
Von beiden Akten hinzufügbar/löschbar.
|
||
- Neues Model `CustomerReferral` (recruiter/recruited FKs, `recruitedId`
|
||
@unique, `relationship`) + Migration. Beziehungs-Whitelist server-
|
||
seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
|
||
explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
|
||
auditiert.
|
||
- **Nachtrag 2026-07-30:** Beziehungs-Dropdown um Schwiegertochter/
|
||
Schwiegersohn, Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa
|
||
erweitert (Whitelist front- & backend synchron). Bearbeiten-Stift pro
|
||
Zeile (vor der Mülltonne): ändert Beziehung und/oder Gegen-Kunden;
|
||
neuer `PUT /:customerId/referrals/:referralId` (Whitelist, Doppel-
|
||
Werber-409 mit Self-Ausschluss, Portal-Block, UPDATE auditiert).
|
||
|
||
- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
|
||
- Cache-Header waren schon optimal (index.html `no-store`, Assets
|
||
`immutable`) – aber eine laufende SPA holt sich nach einem Deploy
|
||
nicht von selbst neuen Code. Neuer `useVersionCheck`-Hook holt die
|
||
(immer frische) index.html periodisch + beim Zurückkehren zum Tab,
|
||
vergleicht die Vite-Asset-Hash-Signatur mit dem Startstand. Bei
|
||
Änderung erscheint ein dezentes Banner „Neue Version verfügbar →
|
||
Jetzt neu laden" (`UpdateBanner`, oben im Layout).
|
||
- Kein Backend-/Build-Change nötig; im Dev-Modus automatisch No-op
|
||
(keine `/assets/<hash>`-Dateien → leere Signatur).
|
||
|
||
- [x] **👁 Kundenportal: Toggle „Deaktivierte Verträge anzeigen"**
|
||
- Der Admin-/Mitarbeiter-Blick (CustomerDetail) hatte den Toggle schon;
|
||
jetzt auch in der Kundenportal-Vertragsübersicht (`ContractList`,
|
||
Portal-Zweig). Button nur bei `isCustomerPortal`, reicht
|
||
`includeDeactivated` an die `getTreeForCustomer`-Queries durch
|
||
(`showDeactivated` im Query-Key → frischer Fetch beim Umschalten).
|
||
- Security: nutzt denselben Endpoint hinter `canAccessCustomer`
|
||
(R120) – Portal-Kunde sieht nur eigene/vertretene Bäume, das Flag
|
||
weitet nur den Status-Filter innerhalb der erlaubten Daten. Keine
|
||
neue Exposition.
|
||
|
||
- [x] **🔒 Mobilfunknetz: server-seitige Whitelist (Pentest-INFO)**
|
||
- `mobileNetwork` akzeptierte serverseitig jeden (stripHtml-bereinigten)
|
||
String – das Frontend-Dropdown beschränkte nur clientseitig. Jetzt
|
||
Whitelist `TELEKOM|VODAFONE|TELEFONICA` in `contract.service.ts`
|
||
(`normalizeMobileNetwork`), angewandt in Create- UND Update-Pfad
|
||
(Update-Spread wurde vorher ungefiltert durchgereicht). Unbekannte/
|
||
leere Werte → null.
|
||
|
||
- [x] **🔒 Rate-Limiting: IPv6-Bypass-Härtung (ipKeyGenerator)**
|
||
- Die Rate-Limiter mit eigenem `keyGenerator` (`loginRateLimiter`,
|
||
`staffPasswordReAuthLimiter`) keyten auf die **volle** `req.ip`.
|
||
Bei IPv6 kann ein Angreifer aus seinem zugeteilten Block (/56–/64)
|
||
pro Versuch eine neue Adresse nehmen und so das Per-IP-Limit
|
||
(Login-Bruteforce, Passwort-Set-Reauth) umgehen.
|
||
- Fix: `req.ip` in beiden `keyGenerator` durch `ipKeyGenerator(...)`
|
||
(express-rate-limit v7) ersetzt → IPv6 wird auf das Subnetz
|
||
normalisiert (Library-Default /56), IPv4 unverändert. Verifiziert:
|
||
zwei verschiedene IPv6 im selben /56 ⇒ derselbe Key.
|
||
- Die Limiter OHNE eigenen keyGenerator (Passwort-Reset, Consent)
|
||
machten das schon über den Library-Default – jetzt konsistent.
|
||
(Ersetzt den verworfenen aria-WIP; auf aktuellem Stand neu gebaut.)
|
||
|
||
- [x] **🐞 Spam-Tab: Anhänge aus Junk-Ordner (Pentest R124-Fund)**
|
||
- Beim Spam-Feature wurden `moveEmailToTrash`/`restoreEmailFromTrash`
|
||
auf den echten Junk-Pfad umgestellt, aber vier Attachment-Funktionen
|
||
im Controller (`downloadAttachment`, `saveAttachmentTo`,
|
||
`saveAttachmentAsInvoice`, `saveAttachmentAsContractDocument`)
|
||
bestimmten den IMAP-Ordner weiter hart als
|
||
`email.folder === 'SENT' ? 'Sent' : 'INBOX'`. Für SPAM-Mails landete
|
||
das fälschlich auf INBOX. Da IMAP-UIDs pro Ordner vergeben sind:
|
||
404 (Anhang nicht gefunden) oder – bei UID-Kollision – der falsche
|
||
Anhang aus INBOX (z.B. als Rechnung/Vertragsdokument abgelegt).
|
||
Kein Cross-Customer-Leak (bleibt im selben Postfach), aber echtes
|
||
Datenintegritätsproblem.
|
||
- Fix: an allen vier Stellen dieselbe Junk-Pfad-Logik wie in
|
||
`moveEmailToTrash` + `findJunkFolderPath` importiert.
|
||
- Randfall (Pentester-Nachtrag): findet sich der Junk-Ordner nicht
|
||
(mehr), wird jetzt ein klarer Fehler zurückgegeben statt still auf
|
||
INBOX/`'Junk'` zu raten – in allen 4 Controller-Funktionen (404
|
||
„Anhang nicht abrufbar") und den 2 Service-Funktionen
|
||
(`moveEmailToTrash`/`restoreEmailFromTrash`). Kein `undefined` mehr
|
||
Richtung IMAP-Lib.
|
||
|
||
- [x] **📧 E-Mail-Client: Spam-Ordner als eigener Tab**
|
||
- Neuer Tab „Spam" (zwischen Gesendet und Papierkorb) im E-Mail-Client,
|
||
zeigt den Junk-/Spam-Ordner des gewählten Postfachs – damit
|
||
fälschlich als Spam eingestufte Mails auffindbar sind.
|
||
- Backend: `EmailFolder`-Enum um `SPAM` erweitert (Migration
|
||
`20260727110000_email_folder_spam`, Enum-Wert angehängt). IMAP-Sync
|
||
ermittelt den Junk-Ordnerpfad per Special-Use-Flag `\Junk` +
|
||
Namensliste (`findJunkFolderPath`), `syncAllFoldersForAccount` synct
|
||
ihn zusätzlich als `dbFolder=SPAM`. `getCachedEmails` +
|
||
`getFolderCountsForAccount` um SPAM erweitert.
|
||
- Papierkorb-Verschieben/Wiederherstellen für Spam-Mails nutzt den
|
||
tatsächlichen Junk-Pfad als Quell-/Zielordner.
|
||
- Frontend: Tab + Badge (ungelesen/gesamt), nutzt denselben
|
||
List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam
|
||
möglich (Rettung legitimer Mails).
|
||
|
||
- [x] **📱 Mobilfunk: Feld „Mobilfunknetz" unter Anbieter & Tarif**
|
||
- Neues Dropdown „Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur
|
||
sichtbar wenn Vertragstyp = Mobilfunk. Optionen: Bitte auswählen
|
||
(leer), Telekom, Vodafone, Telefónica.
|
||
- Neues Feld `MobileContractDetails.mobileNetwork` (String, nullable,
|
||
speichert TELEKOM/VODAFONE/TELEFONICA) + Migration
|
||
`20260727100000_mobile_network` (`ADD COLUMN IF NOT EXISTS`).
|
||
String statt Enum, damit weitere Netze ohne Migration möglich sind.
|
||
- Anzeige in der Vertragsansicht (Mobilfunk-Details) mit lesbarem
|
||
Netz-Namen.
|
||
|
||
- [x] **🐞 Lieferbestätigung: eingegebenes Datum ändert Vertragsbeginn nicht**
|
||
- Beim Upload einer Lieferbestätigung mit Datum wurde `startDate` nur
|
||
gesetzt, wenn es noch LEER war (`if (!contract.startDate)`). Hatte
|
||
der Vertrag schon ein (geschätztes) Beginndatum, blieb es trotz
|
||
eingetragenem Lieferdatum stehen.
|
||
- Fix in `maybeActivateOnDeliveryConfirmation`: ein explizit
|
||
eingegebenes Lieferdatum überschreibt den Vertragsbeginn jetzt IMMER
|
||
(die Lieferbestätigung ist das maßgebliche tatsächliche Startdatum).
|
||
Der Fallback „heute" (kein Datum eingegeben) füllt weiterhin nur ein
|
||
leeres Feld, um ein echtes Datum nicht versehentlich zu überschreiben.
|
||
No-op + Audit-Log unverändert. Frontend schickte das Datum bereits
|
||
mit und lädt den Vertrag nach Upload neu – kein FE-Change nötig.
|
||
|
||
- [x] **🎂 Anstehende Geburtstage auch im Dashboard**
|
||
- Die Geburtstags-Sektion gab es bisher nur im Vertrags-Cockpit.
|
||
Jetzt 1:1 auch auf dem Dashboard (nur Mitarbeiter/Admin,
|
||
`enabled: !isCustomer`), gleiche `birthdayApi.getUpcoming(7, 30)`-
|
||
Query + identische Karten-Darstellung (Heute/vergangen/kommend,
|
||
Link auf die Kundenakte). Kein Backend-Change nötig.
|
||
|
||
- [x] **🔴 Pentest R121 – Audit-Verify: Fehlalarm „manipuliert" bei leerem resourceId**
|
||
- `POST /api/audit-logs/verify` meldete ~73% der Einträge als
|
||
manipuliert. Kein echtes Tampering, sondern ein Bug in
|
||
`generateHash`: `resourceId` war beim Schreiben oft `undefined`
|
||
(middleware-Einträge ohne Route-ID). `JSON.stringify` LÄSST einen
|
||
undefined-Wert weg → Hash ohne `resourceId`-Key. In der DB landet
|
||
der Wert aber als `NULL`; `verifyIntegrity`/`rehashAll` lasen ihn
|
||
als `null` zurück und `JSON.stringify({resourceId:null})` schrieb
|
||
ihn REIN → anderer Hash → Fehlalarm für JEDE leere-resourceId-Zeile.
|
||
Die vom Pentester gefundene `durationMs`-Korrelation war ein Proxy:
|
||
middleware-Einträge (durationMs gesetzt) haben oft kein resourceId.
|
||
- Fix: `generateHash` lässt nullish `resourceId` weg – reproduziert
|
||
exakt das historische Schreibverhalten. Kein Caller übergibt je
|
||
`null` explizit (verifiziert) → alle Bestands-Hashes matchen ohne
|
||
Rehash. Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära
|
||
(id>4141) 482/482 valide (vorher alle 289 null-Zeilen geflaggt).
|
||
- Separat/vorbestehend: Einträge VOR Commit `fd55742` („complete new
|
||
audit system") nutzen ein altes Hash-Schema und wurden nie neu
|
||
baselined → scheitern unabhängig davon. Remediation: einmalig
|
||
`POST /api/audit-logs/rehash` (Admin). Nicht Teil dieses Fixes.
|
||
|
||
- [x] **🔧 Pentest R120 – Audit-Log `/:id` mit nicht-numerischer ID → 500 statt 400**
|
||
- Pentester stiess beim Suchen eines `verify-integrity`-Endpoints auf
|
||
einen 500er. Ursache: `GET /api/audit-logs/verify` matcht `GET /:id`
|
||
(das echte Verify ist `POST /verify`), `parseInt("verify")` = NaN →
|
||
Prisma `findUnique({ where:{ id: NaN }})` wirft → 500. Gleiche
|
||
„400 statt 500"-Klasse wie R64.1/R104.1.
|
||
- Fix: `Number.isNaN`-Guard in `getAuditLogById`,
|
||
`getAuditLogsByCustomer` und `updateRetentionPolicy` → sauberer 400.
|
||
|
||
- [x] **🔴 Pentest R120 – CRITICAL IDOR: Vertragsbaum fremder Kunden lesbar**
|
||
- `GET /api/contracts?tree=true&customerId=<fremd>` returnte für
|
||
Portal-User den vollständigen Vertragsbaum eines beliebigen
|
||
Fremdkunden (Name, Kundennummer, Vertragsnummern, Tarife). Der
|
||
`tree=true`-Zweig im Controller returnte **früh**, bevor die
|
||
Portal-User-`customerIds`-Filterung unten für die flache Liste
|
||
griff. Vorbestehender Bug – der Toggle „Deaktivierte anzeigen"
|
||
(siehe unten) hat ihn nur sichtbarer gemacht, weil selbst
|
||
archivierte Fremdverträge mit auftauchten.
|
||
- Fix: `canAccessCustomer(req, res, customerId)` vor dem frühen
|
||
Return ([contract.controller.ts:80](../backend/src/controllers/contract.controller.ts#L80)).
|
||
Prüft eigene Customer-ID + vertretene MIT Live-Vollmacht, sendet
|
||
selbst die 403. Staff (Nicht-Portal) passiert unverändert. Gleiches
|
||
Muster wie Pentest 56.3 bei update/delete.
|
||
- Zusatzbefund (Pentester): liegen gebliebenes `<script>`-Test-
|
||
Artefakt in `providerName`/`tariffName` eines Staging-Vertrags.
|
||
Nicht exploitierbar – keine der 9 `dangerouslySetInnerHTML`-Stellen
|
||
rendert Vertrags-Provider/Tarif-Namen, React escaped sie als
|
||
Text. Neue Writes werden zusätzlich per `sanitizeContractBody`
|
||
(stripHtml) entschärft; der Altwert stammt aus DB-Direkteingabe.
|
||
- Nachtrag (Retest): Der abgewehrte Zugriff wird nun in ZWEI Streams
|
||
protokolliert. Bisher nur `SecurityEvent` (`ACCESS_DENIED`, sichtbar
|
||
unter `/api/monitoring/events`, aber löschbar + nicht hash-verkettet)
|
||
– der Pentester suchte im AuditLog und fand nichts. `emitAccessDenied`
|
||
schreibt jetzt zusätzlich einen tamper-evidenten AuditLog-Eintrag
|
||
(`action: READ`, `resourceType: 'AccessDenied'`, Sensitivity HIGH)
|
||
für alle `canAccess*`-403. Meine ursprüngliche Formulierung „landet
|
||
im Audit" war die falsche Tabelle – jetzt stimmt sie.
|
||
|
||
- [x] **👁 Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"**
|
||
- Der Vertragsbaum beim Kunden (`CustomerDetail` → Tab Verträge)
|
||
blendete `DEACTIVATED`-Verträge komplett aus. Da der jeweils
|
||
aktuellste Vertrag die Baumwurzel ist und Vorgänger als Children
|
||
hängen, verschwand eine ganze Kette aus der Ansicht, sobald die
|
||
Wurzel deaktiviert wurde – genau das passierte, als ein aktiver
|
||
Folgevertrag gelöscht und der Vorgänger vorher auf „deaktiviert"
|
||
gesetzt worden war. Der Vertrag „verschwand" scheinbar, war aber
|
||
nur unsichtbar gefiltert.
|
||
- Fix: `getContractTreeForCustomer(customerId, includeDeactivated)`
|
||
mit optionalem Flag (Default false = bisheriges Verhalten). Query-
|
||
Param `includeDeactivated=true`, Toggle-Button im Kunden-Vertragstab
|
||
(`Eye`/`EyeOff`). `showDeactivated` ist Teil des Query-Keys →
|
||
frischer Fetch beim Umschalten. Deaktivierte Verträge tragen ihr
|
||
graues `DEACTIVATED`-Badge, bleiben also klar erkennbar.
|
||
|
||
- [x] **🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht**
|
||
- Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen
|
||
des Folgevertrags an der `@unique`-Regel auf `Contract.previousContractId`
|
||
– wenn A → B → C stand und B gelöscht wurde, hielt B im UPDATE-Moment
|
||
noch selbst `previousContractId=A`, C sollte auch auf A → Duplicate-
|
||
Entry, das Update brach ab. (2) Der Frontend-`deleteMutation` in
|
||
`ContractDetail` invalidierte die Contract-Listen-Query nicht,
|
||
navigierte nur nach `/contracts` und zeigte den alten Cache.
|
||
Zusammen sah es aus, als wäre die ganze Historie weg – tatsächlich
|
||
stand der Vorgänger noch im Backend.
|
||
- Fix Service: in einer Transaktion erst B löschen (DB-Cascade räumt
|
||
C.previousContractId auf NULL und gibt den A-Slot frei), dann C
|
||
sauber auf A umbiegen. Kein Zwischenzustand mehr sichtbar.
|
||
- Fix Frontend: `queryClient.invalidateQueries(['contracts'])`
|
||
plus `['contract-tree', customerId]` und `['customer', customerId]`
|
||
nach dem Delete. `onError`-Toast ergänzt, damit fehlgeschlagene
|
||
Löschungen nicht mehr still verschwinden.
|
||
|
||
- [x] **📝 DSGVO-Audit: Portaldaten-Opt-out als eigenes CRITICAL-Event**
|
||
- Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des
|
||
`portalCredentialsNotRequired`-Flags emittiert jetzt zusätzlich zum
|
||
generischen Contract-Update-Diff ein dediziertes `UPDATE`-Event
|
||
unter `resourceType: 'ContractPassword'` — landet damit in derselben
|
||
CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads.
|
||
- Motivation: das Setzen des Flags räumt server-seitig
|
||
`portalUsername`/`portalPasswordEncrypted`/`stressfreiEmailId` auf
|
||
`NULL`. Diese Löschung sensitiver Daten ist jetzt unabhängig vom
|
||
generischen Diff nachweisbar. Rücknahme wird ebenfalls geloggt.
|
||
- `details` enthält alte + neue Flag-Werte plus Bool-Marker, welche
|
||
Anmeldedaten vor dem Opt-out belegt waren (keine Klartext-Leckage).
|
||
|
||
- [x] **🔕 Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Opt-out**
|
||
- Dritter Radio-Button in der `Zugangsdaten`-Card des Vertragsformulars:
|
||
„Nicht möglich zu bekommen / nicht benötigt". Wenn gesetzt, unterdrückt
|
||
das Cockpit die Warnung „Portal-Zugangsdaten fehlen" für diesen
|
||
Vertrag (Anbieter hat kein Portal / Kunde bekommt keine Zugangsdaten).
|
||
- Neues Feld `Contract.portalCredentialsNotRequired` (Boolean, default
|
||
false) + Migration `20260717100000_contract_portal_credentials_not_required`
|
||
(`ADD COLUMN IF NOT EXISTS`). Bestand bleibt unverändert warnend.
|
||
- Passwort-Spalte im Formular wird bei Opt-out ausgeblendet; alte
|
||
Username-/Passwort-Werte werden beim Umschalten geleert, damit der
|
||
Vertrag nach dem Speichern nicht mit Leichen zurückkommt.
|
||
|
||
- [x] **🔧 Debug-Hilfe: Fehler bei „PIN/PUK anzeigen" sichtbar machen**
|
||
- Der stumme `alert('PIN/PUK konnte nicht geladen werden')` maskierte
|
||
die eigentliche Ursache. Ersetzt durch Toast mit Backend-Message
|
||
(Vertrag nicht gefunden / Kein Zugriff / Decrypt-Fehler),
|
||
`console.error` mit Stacktrace und einem Info-Toast, falls die SIM
|
||
in der DB gar keine PIN/PUK hinterlegt hat. Kein Verhaltens-Change am
|
||
Endpoint – nur Diagnose.
|
||
|
||
- [x] **🔗 Vertrag-Zugangsdaten: Externtab-Link zu Kunden-Adressen**
|
||
- Kleines Externtab-Icon neben dem Label „Portal Benutzername", öffnet
|
||
`/customers/{id}?tab=stressfrei` in neuem Tab, damit man beim Anlegen
|
||
einer Portal-Wechseladresse nicht durch die Vertragsansicht zurück
|
||
navigieren muss. Sichtbar in beiden Modi.
|
||
- Als R116 vom Pentester geprüft, kein Finding (SPA-Parameter, Backend
|
||
wertet `?tab=` nicht aus; Sub-Route-IDOR-Check auf `canAccessCustomer`
|
||
steht).
|
||
|
||
- [x] **🔧 Pentest R111 – contract-categories: falscher Permission-String**
|
||
- Der Pentester meldete 403 auf `POST/PUT/DELETE /api/contract-categories/*`
|
||
für Admin trotz vorhandener `contract-categories:*`-Permissions.
|
||
Vermutete Regression aus R110, war aber schon seit dem ersten
|
||
Commit so – die Route verlangte `developer:access`, während der
|
||
Permission-Seed den Admins `contract-categories:*` gibt.
|
||
- Fix: Route auf `contract-categories:create/update/delete`
|
||
umgestellt, konsistent mit den sechs Peer-Katalog-Endpunkten
|
||
(platforms/tariffs/cancellation-periods/…). Kommentar aktualisiert,
|
||
damit die historische Design-Restriktion nicht wieder eingebaut wird.
|
||
|
||
- [x] **🔒 Pentest R110 – Mass-Assignment-Whitelist auf 7 Update-Endpunkten**
|
||
- MEDIUM-Finding: `PUT /api/stressfrei-emails/:id` und 6 weitere Update-
|
||
Endpunkte (`platform`, `tariff`, `contractCategory`,
|
||
`cancellationPeriod`, `contractDuration`, `email-providers`) reichten
|
||
`req.body` ungefiltert an Prisma – gleiche Bug-Klasse wie M1
|
||
(Settings Mass Assignment). Nachgewiesen war es via
|
||
`provisionError`-Feld ausserhalb des TS-Types.
|
||
- Fix: sieben Whitelists + `pickXxxUpdate()`-Helper in `sanitize.ts`,
|
||
in den jeweiligen Controllern eingehängt. Nur die vom Service-
|
||
Interface deklarierten Felder passieren.
|
||
- EmailProvider: bewusst ohne `stripHtmlFromStrings`, weil das
|
||
Passwörter/API-Keys mit Sonderzeichen mutiliert hätte.
|
||
|
||
- [x] **🐞 Kündigungsdatum: Cursor sprang beim Tippen aus dem Feld**
|
||
- `<input type=date>` feuerte `onChange` bei jedem Tastendruck; sobald
|
||
z.B. `18.08.0002` ein gültiges Datum ergab, feuerte die PUT-Mutation,
|
||
Server-Antwort re-renderte den Input, Cursor sprang raus, Seite
|
||
scrollte, weiteres Tippen landete im nächsten Feld.
|
||
- Fix: neue `AutosaveDateInput`-Komponente hält den Wert während der
|
||
Eingabe lokal, feuert `onSave` erst bei Blur/Enter. Escape verwirft.
|
||
Angewendet auf beide Kündigungsbestätigungs-Datumsfelder in
|
||
ContractDetail. Andere App-Datumsfelder laufen über react-hook-form
|
||
(batched Submit) und sind nicht betroffen.
|
||
|
||
- [x] **🐞 IMAP-Fehler: „Command failed" ohne Kontext**
|
||
- `imapflow` wirft bei jedem IMAP-NO/BAD-Response nur
|
||
`new Error('Command failed')` und legt Details in `.responseText`/
|
||
`.responseStatus` ab. Wir haben sie nirgends gelesen → User sah
|
||
im Sync-Toast und im Anhang-Download nur "Command failed" ohne
|
||
Ursache. Realer Fall: abgelaufenes Postfach-Passwort.
|
||
- Fix: zentraler `humanizeImapError()`-Helper in `imapService.ts`.
|
||
Extrahiert responseText/responseStatus, erkennt Auth-Fehler,
|
||
UID-Stale, TLS-/Netzwerk-Fehler und gibt jeweils eine klare
|
||
Meldung mit Handlungsanweisung zurück. Angewendet auf
|
||
`fetchAttachmentInner`, `downloadAttachment`-Controller und
|
||
`syncEmailsForAccount`.
|
||
|
||
- [x] **🆕 Bankkarte-Beschreibung in Vertragsansicht + Auswahl**
|
||
- Nach dem Anlegen des `description`-Feldes (Bankkarten-Modal) wird
|
||
die Beschreibung jetzt auch in der Vertragsansicht (Bankkarten-Card)
|
||
und im ContractForm-Select angezeigt. Im Select mit
|
||
Whitespace-Flatten und 60-Zeichen-Cap, damit lange Notizen den
|
||
Select nicht sprengen.
|
||
|
||
- [x] **🔒 Pentest R104.1 – Salutation-Endpunkte: 404 statt 500**
|
||
- INFO-Finding: `GET/PUT /api/customers/:id/salutation-preference`
|
||
warfen bei nicht-existierendem Customer 500 statt 404.
|
||
- Ursache: `canAccessCustomer` prüft für Staff-User nur Portal-Flag,
|
||
kein Existenz-Check. Service warf `new Error(...)`, Controller-
|
||
Catch mappte generisch auf 500.
|
||
- Fix: neuer Helper `assertCustomerExists()` im Service wirft
|
||
`ApiError(404, ...)`. Die drei Controller-Catches respektieren
|
||
jetzt `ApiError.statusCode`. Auch `set`/`clear` prüfen Existenz
|
||
(sonst käme ein Prisma-P2003 als 500 durch).
|
||
|
||
- [x] **🔒 Pentest R102 – Interne Vertragsnummer nachziehen**
|
||
- INFO-Finding: `contractNumber` (interne CRM-Nummer) hatte im
|
||
Update-Path keine Whitelist. `<script>alert(1)</script>` →
|
||
`stripHtml` → leerer String → Vertragsnummer silent überschrieben.
|
||
- Fix: `contractNumber` zu `CONTRACT_IDENTIFIER_FIELDS` hinzugefügt –
|
||
bestehende R86-Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) greift
|
||
automatisch, R87-Fix (Raw-Input vor stripHtml) ist auch drin.
|
||
Legitime `GAS-MPVJ4P10FE8` bleibt OK, alle Angriffs-Payloads → 400.
|
||
- Doku in `SECURITY-HARDENING.md § Runde 102`.
|
||
|
||
- [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log**
|
||
- Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
|
||
bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
|
||
`fileDownload.controller` ist eigentlich korrekt – sauberer Magic-
|
||
Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest
|
||
gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
|
||
aber nicht reproduzieren.
|
||
- Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
|
||
extrahiert, finally-Block schließt File-Descriptor garantiert,
|
||
Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
|
||
- Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
|
||
es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
|
||
R30.13).
|
||
- Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
|
||
Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
|
||
der Fall im Prod-Log auf, falls er nochmal auftritt – bisher
|
||
war's silent.
|
||
|
||
- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler**
|
||
- R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
|
||
erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
|
||
Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
|
||
durchgehen.
|
||
- R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im
|
||
Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
|
||
bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
|
||
- Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
|
||
läuft **vor** dem `sendEmail`-Aufruf:
|
||
- `attachments` muss Array oder undefined sein
|
||
- max 25 Anhänge
|
||
- jeder: `filename` non-empty String, `content` non-empty Base64-
|
||
String (Regex), optional `contentType` String
|
||
- max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
|
||
kein Buffer.from-Aufruf während der Validierung)
|
||
- Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
|
||
grün inkl. aller R97.1-Pentest-Payloads.
|
||
|
||
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
|
||
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
||
"Datei anhängen":
|
||
- **Vertragsdokumente**: listet alle am Vertrag gespeicherten Dokumente
|
||
gruppiert nach `documentType`. Auswahl → Server-Download via
|
||
`fileUrl` (Token-Auth, Per-File-Ownership-Check greift) → base64 →
|
||
direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit.
|
||
- **Kundendaten einfügen**: zeigt nur Sections die tatsächlich Daten
|
||
haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag,
|
||
Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank +
|
||
Ausweis zusätzlich Sub-Checkbox "als PDF anhängen", wenn ein
|
||
`documentPath` vorhanden ist.
|
||
- Beim Bestätigen werden die Text-Blöcke an das Body-Ende gehängt
|
||
(mit `\n\n`-Separator), Anhänge per `serverFileToAttachment` aus
|
||
`composeAttachmentHelpers.ts` gezogen. Anhang-Limit (25 MB gesamt)
|
||
wird beidseitig geprüft, drüberlaufende Dateien werden mit Toast
|
||
übersprungen statt silent weggeschluckt.
|
||
- Helpers (`composeAttachmentHelpers.ts`):
|
||
- `serverFileToAttachment(path, filename)` – fetch via Token-URL
|
||
→ Blob → base64 → `EmailAttachment`.
|
||
- `totalAttachmentBytes` – Größen-Check unter Berücksichtigung der
|
||
~33 % base64-Overhead.
|
||
- `bankCardAttachmentName` / `identityDocAttachmentName` –
|
||
sprechende Dateinamen für den Empfänger.
|
||
|
||
- [x] **🔒 Pentest R95 – Portal-Username (Manual-Modus) härten**
|
||
- R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor
|
||
sobald der Wert in Mail-Templates / PDF-Footer landet.
|
||
- R95.3 (LOW): `<script>…</script>@x.de` → silent stripHtml-Mutation
|
||
(R87.1-Pattern, dritter Treffer auf demselben Bug).
|
||
- R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400.
|
||
- Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist
|
||
`^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle
|
||
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
|
||
Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt
|
||
validiert (R87-Pattern). Eingehängt in `sanitizeContractBody`.
|
||
- Frontend: `maxLength={100}` am Input.
|
||
- **R95.2 bewusst nicht übernommen** (Email-Format-Pflicht): das
|
||
Feld ist im Manual-Modus nicht zwingend eine E-Mail – Vodafone,
|
||
1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als
|
||
Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`.
|
||
|
||
- [x] **🔒 Pentest R93 – Leerer String != fehlender Param**
|
||
- R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId`
|
||
weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
|
||
Pentester-Spec: leerer String ist KEINE gültige Zahl.
|
||
- Fix im `parsePositiveIntQuery()`-Helper: striktere Absent-Logik
|
||
(`v === undefined` ist absent; `''`, `' '`, alles andere muss
|
||
parsen). Required + optional Modes unverändert.
|
||
- Float-Grenzfall (`accountId=5.5` → 5 via `parseInt`) bleibt als
|
||
by-design akzeptiert (Pentester-Bestätigung, kein Security-Impact).
|
||
|
||
- [x] **🔒 Pentest R92 – Strict-400 für accountId auf Vertrags-Endpunkten**
|
||
- R91-Fix war silent-undefined bei invaliden Werten: `accountId=abc`
|
||
auf `GET /contracts/:id/emails` ergab "kein Filter" → Mailbox-
|
||
Isolation brach (alle Postfächer sichtbar). Pentester R92: per
|
||
Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400.
|
||
- Fix: `parsePositiveIntQuery(v, label, res, { required? })`
|
||
ersetzt den alten silent-Helper. Modes:
|
||
- default (optional): fehlend/leer → `undefined` (kein Filter),
|
||
invalid → 400
|
||
- `{ required: true }`: fehlend/leer **oder** invalid → 400
|
||
- Verteilung:
|
||
- Contract-Emails, Contract-Folder-Counts: `{ required: true }`
|
||
- Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox-
|
||
View ist legitim), invalid → 400
|
||
- Frontend hat schon ein `enabled: !!selectedAccountId`-Guard auf
|
||
den Vertrags-Queries – kein UX-Bruch.
|
||
|
||
- [x] **🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param**
|
||
- R91.1 (LOW): `accountId=abc` → `parseInt('abc')` = `NaN` → der
|
||
Ternary im Controller gab `NaN` an den Service, `if (NaN)` ist
|
||
falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit
|
||
ungültigem `accountId` sah alle Mailbox-Mails für seinen Vertrag
|
||
statt nur die aus dem gewählten Postfach (kein Cross-Customer-
|
||
Leak — `canAccessContract` greift weiter).
|
||
- Fix: zentraler `parsePositiveIntParam()` im `cachedEmail.controller.ts`,
|
||
der nur positive Ganzzahlen aus dem Query-String akzeptiert und
|
||
alles andere zu `undefined` macht. Eingesetzt in allen 5
|
||
Endpunkten, die `accountId`/`contractId` aus Query nehmen
|
||
(Contract-Emails, Contract-Folder-Counts, Customer-Emails,
|
||
Trash, Trash-Count) – auch da, wo der Pentester nicht getestet
|
||
hat, weil derselbe Pattern überall stand.
|
||
|
||
- [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht**
|
||
- Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im
|
||
Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN Postfächern
|
||
des Kunden angezeigt, egal welches Postfach im Selector aktiv war.
|
||
Im Vertrag fehlte zusätzlich der Vertrags-Filter für den Papierkorb.
|
||
- Backend:
|
||
- `getEmailsForContract` controller akzeptiert jetzt `accountId`-
|
||
Query-Param und reicht ihn als `stressfreiEmailId` an
|
||
`getCachedEmails` weiter (der hat den Filter eh schon implementiert,
|
||
nur niemand hat ihn aufgerufen).
|
||
- `getTrashEmails` (controller + service) akzeptiert `accountId` und
|
||
`contractId` als optionale Filter. Default-Verhalten unverändert,
|
||
wenn keiner gesetzt ist.
|
||
- `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`,
|
||
bekommt zusätzlich `trash` + `trashUnread` ins Result – sonst läge
|
||
der Trash-Badge im Vertrag wieder account-global, während die Liste
|
||
contract-scoped ist.
|
||
- Frontend:
|
||
- `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts`
|
||
nehmen den Filter entgegen.
|
||
- `ContractEmailsSection` reicht `selectedAccountId` in alle drei
|
||
Queries durch und nimmt es in den queryKey mit auf – sonst greift
|
||
der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge
|
||
kommt jetzt aus den contract-scoped Counts, damit Badge und Liste
|
||
synchron laufen.
|
||
- `EmailClientTab` reicht `selectedAccountId` in die Trash-Query
|
||
durch (Inbox/Sent waren schon korrekt).
|
||
|
||
- [x] **🔒 Pentest R89 – Provider-Adressfelder härten**
|
||
- R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)`
|
||
statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit
|
||
200 OK gespeichert.
|
||
- R89.2 (LOW): `stripHtml` lief vor dem Length-Check – `<script>…</script>`
|
||
reduzierte auf leeren String → `null` in der DB → vorheriger Wert
|
||
silent überschrieben (R87.1-Pattern auf Adress-Feldern).
|
||
- Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input,
|
||
max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control-
|
||
Chars außer `\n`. CRLF → LF normalisiert vor Length-Check.
|
||
Eingehängt in `stripProviderStrings`.
|
||
- R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt – Pentester
|
||
bestätigt "kein unmittelbares Risiko", React escaped korrekt,
|
||
sind legitime Bestandteile mehrzeiliger Postadressen.
|
||
- Doku in `SECURITY-HARDENING.md § Runde 89`.
|
||
|
||
- [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten**
|
||
- Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`,
|
||
`contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,
|
||
`cancellationFax`, `cancellationAddress`. Postadressen als `TEXT`
|
||
(mehrzeilig), Rest `VARCHAR(191)`. Migration
|
||
`20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`.
|
||
- Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt &
|
||
Kündigung** unterhalb der Auto-Login-Felder, getrennt in zwei
|
||
Untergruppen (Kontakt / Kündigung) mit kleinen Headern.
|
||
Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen
|
||
als `<textarea rows={3}>` mit `maxLength={500}`.
|
||
- Backend-Validierung: contactEmail/cancellationEmail laufen durch
|
||
`isValidEmail` (Header-Injection-Schutz für Mail-Templates),
|
||
contactPhone/contactFax/cancellationFax durch `sanitizePhoneField`
|
||
(kein CRLF/Control-Char), Postadressen durch `sanitizeNotes` mit
|
||
500-Cap.
|
||
- Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen
|
||
Felder beim Backup/Restore verloren.
|
||
|
||
- [x] **🔒 Pentest R87 – Whitelist vor Sanitizer (silent-mutation-Schutz)**
|
||
- R87.1 (LOW): `stripHtml` lief im R86-Fix VOR der Whitelist.
|
||
Tags wurden still weggestrippt → 200 OK mit mutierten Werten,
|
||
`<script>…</script>` reduzierte auf leeren String → `null` in
|
||
der DB → vorheriger Wert ohne Fehlermeldung überschrieben.
|
||
- Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
|
||
umgedreht – `validateContractIdentifier` läuft jetzt direkt
|
||
gegen den Raw-Input. Die strikte Whitelist lehnt eh alles
|
||
ab, was stripHtml normalerweise auffangen würde (Tags,
|
||
Schemes, Zero-Width, Homoglyphe, Percent-Encoding) – Defense-
|
||
in-Depth bleibt, nur ehrlich (400 statt silent-200).
|
||
- Single-Line-Patch in `contract.controller.ts`, Doku in
|
||
`SECURITY-HARDENING.md § Runde 87`.
|
||
|
||
- [x] **🔒 Pentest R86 – Vertrags-Identifier härten**
|
||
- R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
|
||
Auftragsnummer warfen 500 (DB-Overflow `VARCHAR(191)`) statt 400.
|
||
- R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag
|
||
(`foo" onerror=…`) überlebte `stripHtml` – kein Risiko in der React-
|
||
UI, aber relevant für PDF/Mail/CSV-Export.
|
||
- Fix: zentraler `validateContractIdentifier()` in `sanitize.ts`
|
||
mit Max-100 und Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$`. Bewusst
|
||
literales Space statt `\s`, damit kein CRLF/Tab passiert (Header-
|
||
Injection). Wirft `ApiError(400, …)` mit klarer Meldung.
|
||
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
|
||
fünf Identifier-Felder bei Create/Update. ContractForm bekommt
|
||
`maxLength={100}` als UX-Schicht. Doku in
|
||
`docs/SECURITY-HARDENING.md` § Runde 86.
|
||
|
||
- [x] **🆕 Vertrag: Auftragsnummer bei Vertriebsplattform**
|
||
- Neues optionales Feld `Contract.orderNumberAtSalesPlatform`
|
||
(`VARCHAR(191) NULL`), Migration
|
||
`20260619100000_contract_order_number_at_sales_platform` mit
|
||
`IF NOT EXISTS`.
|
||
- Im ContractForm direkt **vor** der Kundennummer der
|
||
Vertriebsplattform angeordnet (Wunsch des Users).
|
||
ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor
|
||
den anderen beiden Sales-Platform-Feldern.
|
||
- Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und
|
||
XSS-Strip-Allowlist (`CONTRACT_DISPLAY_STRING_FIELDS`)
|
||
mitgezogen, damit das neue Feld die gleichen Garantien wie
|
||
Kunden-/Vertragsnummer bekommt.
|
||
|
||
- [x] **🐞 Entfernte Weiterleitungen kamen via Auto-Import zurück**
|
||
- Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
|
||
Plesk-Sync läuft → Auto-Import (`Pentest 83.x`) sieht „c ist in
|
||
Plesk aber nicht in DB" → schreibt `c` zurück in
|
||
`additionalForwardingEmails` → Diff sagt nichts zu entfernen.
|
||
- Ursache: Auto-Import war für **alle** Sync-Aufrufe aktiv. Beim
|
||
Sync-Button-Klick will der User Plesk-Bestand übernehmen (Import
|
||
sinnvoll), beim Add/Remove im Modal ist die DB-Liste die
|
||
explizite Intent (Import schädlich).
|
||
- Fix: `syncForwardingForEmail(id, { autoImportPleskMembers? })`
|
||
mit Default `true`. `setAdditionalForwards` ruft mit
|
||
`false` auf → entfernte Adressen verschwinden jetzt sauber bei
|
||
Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin
|
||
alte Bestands-Members).
|
||
|
||
- [x] **🐞 Plesk-Sync: `-forwarding-addresses set:` existiert gar nicht**
|
||
- Folge-Bug nach `a83358b`/`24e152b`: Sync verändert Plesk weiterhin
|
||
nicht. `plesk bin mail --help` zeigt: `-forwarding-addresses`
|
||
akzeptiert ausschließlich `add:` und `del:` – unser `set:` wurde
|
||
von Plesk silent verworfen. Außerdem gibt es keine separate
|
||
`-mailgroup`-Option; was Plesk im `--info` als `Mailgroup: true`
|
||
zeigt, ist genau das, was `-forwarding true` in der CLI setzt
|
||
(doppelt benannt). Mein vorheriges `-mailgroup false` lief auf
|
||
den Phantom-Parameter und triggerte `Unrecognized option`.
|
||
- `updateForwardTargets` baut jetzt den Diff: aktuelle Mailgroup-
|
||
Members (aus `emailExists`) gegen Soll-Liste; `del:<entfernt>` +
|
||
`add:<neu>` in zwei separaten CLI-Calls. Idempotent.
|
||
Case-insensitive – `Bruns.Gerhard` ≡ `bruns.gerhard`.
|
||
- Phantom-`-mailgroup`-Parameter entfernt.
|
||
- Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
|
||
nichts entfernt, nur `bzirks@gmx.de` hinzugefügt.
|
||
|
||
- [x] **🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten**
|
||
- **83.1 MEDIUM:** Auto-Import in `syncForwardingForEmail` umging
|
||
`assertValidForwardingEmail`. Plesk-Member wie `attacker@plesk.internal`
|
||
oder `evil@x.local` wären ohne TLD-Block-Check (71.1) in unsere
|
||
DB gewandert. Fix: jeder importierte Member läuft durch
|
||
`assertValidForwardingEmail`; ungültige werden silent gedroppt
|
||
und auf `console.debug`-Level geloggt.
|
||
- **83.2 LOW:** Self-Forward-Schutz (81.1) lief nur im Add-Pfad.
|
||
Wenn Plesk die eigene Adresse als Mailgroup-Member führte, wäre
|
||
sie beim Auto-Import in die DB-Liste gerutscht → nach dem
|
||
Umschalten auf Forwarding Mail-Loop. Fix:
|
||
`seenKeys.add(canonicalEmailKey(stressfreiEmail.email))` vor
|
||
der Import-Schleife.
|
||
- **83.3 INFO:** PII-Log auf `console.debug` umgestellt (statt
|
||
`console.log` auf Default-Level).
|
||
- Smoke-Test mit gemischter Plesk-Liste: `karibik61@web.de` (legit)
|
||
importiert, `attacker@plesk.internal` + `evil@x.local` per 83.1
|
||
abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2
|
||
abgelehnt, Customer-Stamm-Mail + Default deduped.
|
||
|
||
- [x] **🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht**
|
||
- Prod-Bug: User trägt zusätzliche Weiterleitung ein, Toast meldet
|
||
Erfolg, aber Plesk übernimmt nichts. Ursache: Plesk hat zwei
|
||
Verteil-Mechanismen, **Mailgroup** (alte CLI-Anlagen,
|
||
`Group member(s):`) und **Forwarding** (`Forward request:`). Unser
|
||
Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup
|
||
→ unsere `set:`-Befehle landeten in einer ungenutzten Tabelle.
|
||
Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt
|
||
wurden (Forwarding-Modus von Anfang an).
|
||
- `EmailExistsResult` um `mailgroupActive` + `mailgroupMembers` +
|
||
`forwardingActive` + `forwardingTargets` erweitert.
|
||
- `pleskProvider.emailExists` parst alle vier Felder aus dem
|
||
`--info`-stdout (`Mailgroup: true|false`, `Group member(s): ...`,
|
||
`Forward request: ...`).
|
||
- `pleskProvider.updateForwardTargets` setzt jetzt zusätzlich
|
||
`-mailgroup false`, damit der Legacy-Mechanismus deaktiviert wird
|
||
und nur noch Forwarding aktiv ist.
|
||
- `syncForwardingForEmail`: vor dem Plesk-Update werden bestehende
|
||
Mailgroup-Members + Forwarding-Targets abgeholt und in unsere
|
||
`additionalForwardingEmails`-Liste **importiert** (canonical-Key-
|
||
Dedup). Verlustfrei – kein bestehender Empfänger fällt beim
|
||
Umschalten auf Forwarding raus. Import-Fehler werden geloggt,
|
||
aber der eigentliche Sync läuft trotzdem.
|
||
|
||
- [x] **🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider**
|
||
- Bug: User konnte die Stressfrei-Adresse selbst (z.B.
|
||
`max.mustermann@stressfrei-wechseln.net`) als zusätzliches
|
||
Weiterleitungsziel eintragen – auch Plus-Varianten davon. Plesk
|
||
leitet auf sich selbst um → Mail-Loop.
|
||
- Backend (`setAdditionalForwards`): zieht jetzt zusätzlich
|
||
`meta.email` aus der DB und vergleicht `canonicalEmailKey(eintrag)`
|
||
gegen `canonicalEmailKey(meta.email)`. Bei Treffer hartes
|
||
`ApiError(400)` mit klarer Self-Forward-Meldung statt silent dedup
|
||
– der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
|
||
- Frontend (`AdditionalForwardsModal`): zusätzlich proaktive
|
||
Validierung im Sub-Modal mit identischem `canonicalize`-Helper
|
||
(Plus-Tag strippen, lowercase). Neuer Prop `selfEmail`, damit
|
||
auch der Create-Modus (vor dem Persistieren) den Check fahren
|
||
kann. Spart einen Roundtrip + zeigt sofort eine sprechende
|
||
Meldung „… zeigt auf die Adresse selbst – Mail-Loop".
|
||
|
||
- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
|
||
- `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
|
||
den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
|
||
auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für
|
||
`4.0` und Exp-Notation `4e1`.
|
||
- Fix: vorm Parsen Regex `/^\d+$/` auf die rohe `req.params.<name>`-
|
||
Eingabe. Nur reine Ziffern erlaubt – keine Floats, Exp, Vorzeichen,
|
||
Whitespace, Hex.
|
||
- Smoke-Test (17 Cases): `4.0`, `4.5`, `4e1`, `4E2`, `0`, `-4`, `+4`,
|
||
`0x10`, `1.0e0`, leading/trailing Space alle abgelehnt; `1`, `4`,
|
||
`100`, `9999999` durchgewunken.
|
||
|
||
- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
|
||
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
|
||
anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
|
||
hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
|
||
- Service: Vor `prisma.create` jetzt `findFirst` auf
|
||
`(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
|
||
Unterschiedliche Meldung für aktive vs. inaktive Duplikate
|
||
(Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
|
||
- `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
|
||
- Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
|
||
(vorher pauschal 400) → 409 kommt sauber durch.
|
||
- Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
|
||
nur ins Leere lief.
|
||
|
||
- [x] **🔒 Pentest 71.1–71.4: Härtung der Zusatz-Weiterleitungen**
|
||
- **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
|
||
`corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
|
||
`example`, `intranet`, `localdomain`, `arpa`) werden in
|
||
`assertValidForwardingEmail` jetzt hart abgelehnt. Verhindert
|
||
Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
|
||
- **71.2 LOW:** Neuer Helper `canonicalEmailKey` normalisiert Mail-
|
||
Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen,
|
||
lowercase). `billing+pentest@x.de` und `billing@x.de` werden als
|
||
dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail
|
||
und im sync-Pfad gegen `config.defaultForwardEmail`.
|
||
- **71.3 INFO:** Neuer `requireIdParam(req, res, paramName)`-Helper
|
||
fängt nicht-numerische Route-Parameter und liefert 400 statt 500.
|
||
Alle acht parseInt-Stellen in `stressfreiEmail.controller.ts`
|
||
umgestellt (auch über das gemeldete Finding hinaus).
|
||
- **71.4 INFO:** `setAdditionalForwards` rollt den DB-Stand bei
|
||
Provider-Sync-Fehler zurück, damit DB und Plesk nicht
|
||
auseinanderlaufen. Vorheriger `additionalForwardingEmails`-Wert
|
||
wird vor dem Update gemerkt und bei Fail wieder eingespielt.
|
||
- Smoke-Tests bestätigen: 11 reservierte TLDs abgelehnt, 4 echte
|
||
TLDs (`de`, `com`, `co.uk`, `museum`) durchgewinkt, Plus-Tag-
|
||
Strip funktioniert (auch mit Multi-Plus + Casing).
|
||
|
||
- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
|
||
- Im „Adresse hinzufügen"-Modal erscheint der „Weitere
|
||
Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
|
||
anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync
|
||
läuft direkt nach `createEmail` mit der vollen Liste.
|
||
- Sub-Modal generalisiert: `value`/`onChange`-Pattern (controlled).
|
||
Mit `email`-Prop → API-Persist pro Änderung (Edit). Ohne `email`
|
||
→ lokaler State (Create). Counter-Badge am Button zeigt die
|
||
Anzahl Adressen.
|
||
|
||
- [x] **🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele**
|
||
- Neues Feld `StressfreiEmail.additionalForwardingEmails` (Text/
|
||
JSON-Array), Migration `20260608100000_stressfrei_email_additional_forwards`
|
||
mit `IF NOT EXISTS`.
|
||
- `syncForwardingForEmail` zieht die zusätzlichen Adressen mit
|
||
in die Plesk-`set:`-Liste ein (case-insensitive Dedup gegen
|
||
`customer.email` und `config.defaultForwardEmail`).
|
||
- Neuer Endpoint `PUT /api/stressfrei-emails/:id/additional-forwards`
|
||
mit Body `{ emails: string[] }` – ersetzt die Liste und syncht
|
||
direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per
|
||
Regex, Audit-Log.
|
||
- Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button
|
||
(Edit-Modus + `providerStatus === exists`) öffnet ein Sub-Modal
|
||
mit Liste + Add/Remove. Jede Änderung geht sofort live.
|
||
|
||
- [x] **🐞 Modal-Felder ließen sich nicht editieren (Zähler/Bankkarte/Ausweis/Zählerstand)**
|
||
- Vier identische Vorkommen desselben Anti-Patterns wie beim
|
||
AddressModal-Fix von 2026-06-03: `setFormData(getInitialFormData())`
|
||
im Render-Body, getriggert durch `formData.X !== prop.X`. Jeder
|
||
Tastendruck setzte den State zurück.
|
||
- Fix in allen vier Modals (MeterModal, BankCardModal,
|
||
IdentityDocumentModal, MeterReadingModal): nach `useEffect` mit
|
||
`[<entity>?.id]`-Dependency umgezogen.
|
||
|
||
- [x] **🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB**
|
||
- Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos
|
||
haben 4000-6000 px Kante (24 MP), das vergrößert die JPEG-Datei
|
||
auch ohne Re-Encode auf 5-10 MB pro Bild.
|
||
- Fix: Bilder **beim Hinzufügen** auf max. 2400 px lange Kante
|
||
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
|
||
Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied)
|
||
persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler
|
||
PDF-Embed laufen alle auf dem skalierten Bild.
|
||
- Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).
|
||
|
||
- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
|
||
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
|
||
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
|
||
`ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
|
||
`?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
|
||
- `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
|
||
damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.
|
||
|
||
- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
|
||
- Zwei neue optionale Felder
|
||
`Contract.customerNumberAtSalesPlatform` +
|
||
`contractNumberAtSalesPlatform`, Migration
|
||
`20260603150000_contract_sales_platform_numbers` mit
|
||
`IF NOT EXISTS`.
|
||
- Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
|
||
Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
|
||
mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
|
||
(CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
|
||
- Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
|
||
und Audit-Label-Mapping ist gleich mit drin – wurde bisher
|
||
nicht in VVL-Folgeverträge kopiert.
|
||
|
||
- [x] **🆕 Email-Links öffnen im neuen Tab**
|
||
- In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
|
||
auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
|
||
verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
|
||
in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.
|
||
|
||
- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
|
||
- Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
|
||
Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
|
||
Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
|
||
enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
|
||
- Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
|
||
PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
|
||
von Streams (in PDF-Object-Dictionaries) – Binär-Streams enthalten
|
||
Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
|
||
- Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
|
||
durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
|
||
blockiert, clean PDF weiterhin OK.
|
||
|
||
- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
|
||
- `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
|
||
`formData.street !== address.street`. Jeder Tastendruck löste neu
|
||
aus → Reset auf DB-Wert → Cursor sprang zurück → keine Eingabe
|
||
möglich.
|
||
- Fix: in `useEffect` mit `[address?.id]`-Dependency umgezogen.
|
||
Re-Init nur beim Wechsel/Open, nicht bei jedem Render.
|
||
|
||
- [x] **🐞 Upload-Limit: Multer 10 MB → 25 MB (Ausweis-Scans, JPGs→PDF)**
|
||
- Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit
|
||
(auch mit Original-Bytes-Optimierung aus 431792e). Limits in
|
||
`upload.routes.ts`, `gdpr.routes.ts`, `contract.routes.ts` auf
|
||
25 MB hochgezogen. `pdfTemplate.routes.ts` war eh schon bei 20 MB.
|
||
- Frontend: `FileUpload`-Hinweis „max. 10 MB" → „max. 25 MB".
|
||
|
||
- [x] **🆕 SIM-Karten: Checkbox „eSIM" zwischen „Hauptkarte" und „Multisim"**
|
||
- Hardware-Plastikkarte vs. eSIM-Profil ist eine eigene Eigenschaft –
|
||
eSIM kann sowohl Hauptkarte als auch Multisim sein, also zusätzlich
|
||
statt entweder/oder.
|
||
- Schema: `SimCard.isEsim Boolean @default(false)`, Migration
|
||
`20260603100000_sim_card_esim` mit `IF NOT EXISTS`.
|
||
- Backend: alle vier SimCard-Schreibpfade in `contract.service.ts`
|
||
(Create + Update + Follow-Up + Renewal) plus FE-Type-Definition.
|
||
- UI: dritte Checkbox in `ContractForm` zwischen Hauptkarte und
|
||
Multisim. ContractDetail zeigt blauen `eSIM`-Badge neben Hauptkarte.
|
||
|
||
- [x] **🆕 JpgToPdfModal: PDF-Größe drastisch reduziert (Original-Bytes + Quality 0.95)**
|
||
- Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
|
||
Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
|
||
auf (Quality 100 % heißt nicht „identisch zum Original", sondern
|
||
„möglichst viele Bits pro Pixel" – ein schon JPEG-komprimiertes
|
||
Smartphone-Foto wird so künstlich 4-8× größer).
|
||
- **Fix 1:** Wenn Rotation/Flip unverändert (Standardfall), wird die
|
||
Original-DataURL 1:1 in die PDF eingebettet – kein Canvas-Roundtrip,
|
||
keine Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Funktioniert für
|
||
JPEG und PNG (Format-Detection per `data:image/png`-Prefix).
|
||
- **Fix 2:** Bei Transformation: `toDataURL('image/jpeg', 0.95)` statt
|
||
`1.0`. Visuell identisch für Foto-Inhalte (Adobe-Lightroom-Default),
|
||
aber 50-70 % kleiner.
|
||
- Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
|
||
16-30 MB), 2 gedrehte ≈ 5-8 MB.
|
||
|
||
- [x] **🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type**
|
||
- Globaler Error-Handler in `index.ts:461` matcht
|
||
`/sind erlaubt|nicht erlaubt/i` und mappt auf 415. Meine 70.1-
|
||
Message „… WebP erlaubt" (ohne „sind") rutschte durch und landete
|
||
bei 500 + Error-Log-Spam.
|
||
- Fix: 1 Zeile in `contract.routes.ts` – `… WebP-Dateien sind
|
||
erlaubt` macht den Regex glücklich. Andere Routes
|
||
(`upload.routes.ts`, `gdpr.routes.ts`, `pdfTemplate.routes.ts`,
|
||
`factoryDefaults.routes.ts`, `appSetting.routes.ts`) nutzen alle
|
||
schon „sind erlaubt".
|
||
|
||
- [x] **🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter**
|
||
- `contract.routes.ts` Vertragsdokumente: Multer-fileFilter blockte
|
||
`image/gif` und `image/webp`, obwohl `validateUploadedFile` beide
|
||
Typen zulässt. Folge: GIF mit korrektem MIME `image/gif` → 415, GIF
|
||
mit gespooftem MIME `image/jpeg` → akzeptiert (vom Magic-Byte als
|
||
`.gif` erkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte
|
||
Guard), aber inkonsistent.
|
||
- Fix: Whitelist um `image/gif` + `image/webp` ergänzt → konsistent
|
||
zum zentralen `validateUploadedFile` und zu `upload.routes.ts`.
|
||
|
||
- [x] **🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert**
|
||
- `contract.routes.ts` Vertragsdokumente-Upload hatte bisher nur den
|
||
PDF-Inhalts-Scan (`scanUploadedPdfIfPresent` aus 68.1). JPG/PNG-
|
||
Uploads waren ungeprüft – kompensiert durch Download-Layer
|
||
(`fileDownload.controller.ts` liefert nur bei Magic-Byte-Match
|
||
inline aus, sonst attachment). Pentester selbst: "ohne Exploit-
|
||
Pfad", aber inkonsistent zu `upload.routes.ts`.
|
||
- **Refactor:** `detectType` + `validateUploadedFile` aus
|
||
`upload.routes.ts` in neue Middleware
|
||
`middleware/uploadFileTypeValidator.ts` ausgelagert (Single Source
|
||
of Truth). Beide Routes nutzen jetzt denselben Helper.
|
||
- **contract.routes.ts:** `validateUploadedFile` ersetzt das
|
||
schlankere `scanUploadedPdfIfPresent` – jetzt greift Magic-Byte +
|
||
canonical Rename + PDF-Scan für Vertragsdokumente analog zu allen
|
||
anderen Upload-Pfaden.
|
||
- **pdfUploadSafety.ts:** `scanUploadedPdfIfPresent` entfernt (tot,
|
||
da nur in contract.routes verwendet wurde). `requireSafeUploadedPdf`
|
||
bleibt für gdpr.routes Vollmacht + pdfTemplate.routes.
|
||
|
||
- [x] **🔒 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Inhalts-Validierung + Modal-Limit**
|
||
- **68.1 PDF-Active-Content-Filter:** Magic-Byte-Check prüfte bisher
|
||
nur `%PDF-`. PDFs mit `/JavaScript`, `/JS`, `/Launch` (externes
|
||
Programm), `/EmbeddedFile`, `/RichMedia` (Flash) wurden inline an
|
||
den Viewer ausgeliefert – Browser-PDF-Viewer (Chrome/Firefox)
|
||
ignorieren JS, Adobe Acrobat aber nicht.
|
||
- Neuer Helper `assertSafePdf(buf)` in `utils/sanitize.ts`:
|
||
String-Scan auf die fünf Action-Pattern (case-sensitive nach
|
||
PDF 32000-1:2008 §7.3.5). Wirft `ApiError(415, ...)` bei Treffer.
|
||
- Neue Middleware `pdfUploadSafety.ts` mit zwei Varianten:
|
||
- `requireSafeUploadedPdf` – Datei MUSS PDF sein, sonst 415.
|
||
- `scanUploadedPdfIfPresent` – durchwinkt JPG/PNG, scannt nur PDFs.
|
||
- Eingehängt:
|
||
- `upload.routes.ts` (Magic-Byte-Validator erweitert)
|
||
- `gdpr.routes.ts` Vollmacht-Upload
|
||
- `pdfTemplate.routes.ts` Template-Upload
|
||
- `contract.routes.ts` Vertragsdokumente
|
||
- `cachedEmail.controller.ts` Email-Anhang-Pfade (3 Stellen:
|
||
saveAttachmentTo, saveAttachmentAsInvoice,
|
||
saveAttachmentAsContractDocument)
|
||
- **Inline-Vorschau bleibt erhalten** – das war die explizite
|
||
Anforderung (Augen-Button öffnet PDF im neuen Tab). Pentester-
|
||
Empfehlung „disposition=inline abschalten" wurde bewusst NICHT
|
||
umgesetzt, weil sie das eigentliche Acrobat-Risiko nicht löst
|
||
(PDF auf Disk + Doppelklick → Acrobat → JS läuft trotzdem).
|
||
- Edge-Case-Test bestätigt: `/JSXForm` und `/JavaScriptFooter` werden
|
||
NICHT als JavaScript-Action erkannt (word-boundary `\b` greift).
|
||
- **68.2 Modal-Limit:** `JpgToPdfModal` hatte kein Bild-/Größen-Limit.
|
||
Jetzt `MAX_IMAGES = 50` + `MAX_IMAGE_BYTES = 25 MB` pro Bild.
|
||
UX-Schutz, kein Security-Bug (Self-DoS only).
|
||
|
||
- [x] **🆕 JPGs → PDF: Button überall bei PDF-Upload**
|
||
- Neue Komponente `JpgToPdfModal` (lokal im Browser via `jspdf`,
|
||
keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per
|
||
Klick, Drag&Drop oder `Strg+V` (Clipboard-Image), Reihenfolge
|
||
per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung +
|
||
Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite,
|
||
A4 mit automatischer Hoch-/Querformat-Wahl je Bild.
|
||
- `FileUpload`-Komponente (11 Stellen: Datenschutz-PDF,
|
||
Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung,
|
||
Handelsregister, Kündigungsschreiben + -Bestätigung +
|
||
deren Optionen) bekommt automatisch einen sekundären
|
||
"JPGs → PDF"-Button, wenn `accept` PDF einschließt.
|
||
- Direkt-Inputs ebenfalls erweitert: Vertragsdokumente
|
||
(ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab),
|
||
Rechnungen (InvoicesSection).
|
||
- PdfTemplates **bewusst ausgenommen** – braucht echte
|
||
AcroForm-PDFs mit Formularfeldern, Bild-PDFs wären unbrauchbar.
|
||
|
||
- [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"**
|
||
- `customerEmailLabel` existierte im Backend (Schema +
|
||
Update-Logik + Public-Endpoint), war im UI aber nicht
|
||
erreichbar – das Label kam ausschließlich aus
|
||
`deriveLabelFromDomain`. Jetzt neuer optionaler Input
|
||
„Bezeichnung im UI" unter dem Domain-Block in
|
||
Einstellungen → E-Mail-Provider. Leer = Auto-Ableitung,
|
||
befüllt = überschreibt das Tab-Label und alle anderen
|
||
Stellen, die `customerEmailLabel` nutzen (max 60 Zeichen).
|
||
|
||
- [x] **🆕 SIM-Karten: Feld „Kartennutzer"**
|
||
- Bei Firmen- und Familienverträgen weicht der
|
||
Vertragsinhaber (Firma/Eltern) vom tatsächlichen Nutzer
|
||
(Mitarbeiter/Kind) ab. Neuer optionaler `SimCard.cardUser`
|
||
(String), Migration `20260601100000_sim_card_user` mit
|
||
`IF NOT EXISTS`.
|
||
- ContractForm: eigene Zeile „Kartennutzer" oberhalb der
|
||
technischen SIM-Felder mit erklärendem Placeholder.
|
||
- ContractDetail: zeigt „Nutzer: \<Name\>" neben den
|
||
Hauptkarte/Multisim-Badges (nur wenn gefüllt).
|
||
|
||
- [x] **🆕 Vorgängervertrag-Modal: Kundennr./Vertragsnr. sichtbar machen**
|
||
- Die „Anbieter & Tarif"-Card im ContractDetailModal war
|
||
nur sichtbar, wenn Provider oder Tarif gesetzt waren –
|
||
Bei Entwürfen ohne Anbieter wurden so auch
|
||
`customerNumberAtProvider` + `contractNumberAtProvider`
|
||
versteckt, obwohl sie gepflegt sein können. Fix:
|
||
Sichtbarkeitsbedingung um die beiden Felder erweitert.
|
||
|
||
- [x] **🆕 ContractEmails-Card: Quicklinks auch ohne Postfach**
|
||
- „Postfach öffnen" + „Stressfrei wechseln Adressen" waren
|
||
nur im Normal-Zweig sichtbar. Jetzt in einer gemeinsamen
|
||
`cardTitle`-Variable extrahiert und auch im
|
||
„Kein-Mailbox"-State sichtbar.
|
||
|
||
- [x] **🆕 Vertrags-Forms: Mini-Links zu Stammdaten**
|
||
- In ContractEmailsSection neben „Postfach öffnen" jetzt
|
||
zusätzlich „Stressfrei wechseln Adressen" → Kundenakte-Tab.
|
||
- In ContractForm kleine ExternalLink-Icons neben Select-Labels:
|
||
Lieferadresse/Rechnungsadresse → `?tab=addresses`,
|
||
Bankkarte → `?tab=bankcards`, Ausweis → `?tab=documents`,
|
||
Anbieter + Tarif → `/settings/providers`,
|
||
Vertriebsplattform → `/settings/platforms`.
|
||
- Select-Komponente nimmt jetzt `ReactNode` als label
|
||
(statt nur string), rückwärtskompatibel.
|
||
- In ContractDetail (Strom/Gas Card-Header) zusätzlich
|
||
permanenter „Zähler verwalten"-Link in neuem Tab.
|
||
|
||
- [x] **🆕 Vertragshistorie: Vertragsnummern als Link**
|
||
- Erkennt Vertragsnummern (`PREFIX-RANDOM`) in `title` und
|
||
`description` via Regex, löst sie gegen
|
||
`previousContract` + `followUpContract` des aktuellen
|
||
Vertrags auf und rendert sie als Link in neuem Tab.
|
||
Nicht aufgelöste Nummern bleiben als Text.
|
||
|
||
- [x] **🆕 UI-Bug-Fix: „Wurde sondergekündigt?"-Checkbox-Label**
|
||
- Label-Klasse war `flex` → Block-Layout über volle
|
||
`col-span-2`-Breite. Klick rechts neben dem Text
|
||
triggerte ungewollt die Checkbox. Fix: `inline-flex`.
|
||
|
||
- [x] **🆕 „Zähler verwalten"-Link im Folgezähler-Form**
|
||
- In der SuccessorMeterForm (Vertragsansicht) im Header
|
||
rechts neben „Folgezähler hinzufügen (Zählerwechsel)"
|
||
ein Link in neuem Tab zur Zähler-Verwaltung des Kunden,
|
||
damit man bei „Kein passender Zähler verfügbar" direkt
|
||
rüberspringen kann.
|
||
|
||
- [x] **🆕 Folgezähler-Forms: Checkbox „Alten Zähler deaktivieren"**
|
||
- Beide Folgezähler-Forms (Kundenakte MeterModal +
|
||
Vertragsansicht SuccessorMeterForm) bekommen eine
|
||
Checkbox, die standardmäßig angehakt ist. Beim Speichern
|
||
wird der Vorgänger automatisch auf `isActive=false`
|
||
gesetzt – ein-klick-fähiger Zählerwechsel.
|
||
- Backend: `createMeter` mit `successorOf` und
|
||
`addSuccessorMeter` akzeptieren `deactivatePredecessor`
|
||
(Default true).
|
||
|
||
- [x] **🆕 Kundenakte → Zähler: Verträge-Aufklappliste + Filter**
|
||
- Pro Zähler ein „Verträge (N)"-Aufklapp, listet alle
|
||
Verträge auf, die diesen Zähler nutzen – als Hauptzähler
|
||
(`energyDetails.meterId`) oder über Folgezähler-Kette
|
||
(`ContractMeter`). Dedupliziert. Jeder Eintrag ist Link
|
||
in neuen Tab mit Vertragsnummer, Anbieter, Status-Badge.
|
||
- Neue Checkbox „Zähler ohne Verträge anzeigen" neben
|
||
„Inaktive anzeigen". Filtert die Liste auf Orphans
|
||
(Zähler ohne Vertragszuordnung):
|
||
| Inaktive | ohne Verträge | Ergebnis |
|
||
|----------|---------------|----------|
|
||
| ☐ | ☐ | nur aktive Zähler (Default) |
|
||
| ☑ | ☐ | alle Zähler |
|
||
| ☐ | ☑ | aktive Zähler ohne Vertrag |
|
||
| ☑ | ☑ | alle Zähler ohne Vertrag |
|
||
|
||
- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
|
||
- Folgeverträge ohne ContractMeter-Eintrag (alte Daten oder
|
||
pure Single-Meter-Verträge) bekamen den Button nie zu
|
||
Gesicht. Fix: Button wird jetzt aus dem if/else gerendert,
|
||
sobald entweder ein Single-Meter oder ContractMeter-
|
||
Einträge vorhanden sind. Im Backend wird der bisherige
|
||
`energyDetails.meterId` bei Single-Meter-Verträgen
|
||
automatisch als ContractMeter (position 0,
|
||
`removedAt` = Wechseldatum) backfillt, damit der alte
|
||
Zähler in der Historie bleibt.
|
||
|
||
- [x] **🆕 Multi-Meter-Verbrauch auf Vertragslaufzeit clampen**
|
||
- Bei Verträgen, die Vorgänger einer Folgevertrags-Kette
|
||
sind, hängen über `ContractMeter` auch Folgezähler dran,
|
||
die nach Vertragsende installiert wurden. Die Berechnung
|
||
nahm `cm.installedAt..cm.removedAt` 1:1 ohne Clamp gegen
|
||
`contract.startDate/endDate` – damit flossen Zählerstände
|
||
aus der Folgevertrags-Phase in den Verbrauch des
|
||
Vorvertrags ein. Fix:
|
||
`meterStart = max(installedAt, contractStart)`,
|
||
`meterEnd = min(removedAt, contractEnd)`,
|
||
Zähler komplett außerhalb der Laufzeit werden übersprungen.
|
||
|
||
- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
|
||
- Strom/Gas-Card zeigt jetzt neben der Zählernummer ein
|
||
rotes „Inaktiv"-Badge (falls deaktiviert) und darunter
|
||
eine kleine Zeile „Standort: …" (falls gepflegt).
|
||
|
||
- [x] **🛡️ Pentest 43.5 (INFO) + 43.6 (MEDIUM): History-XSS + blocked:-Marker**
|
||
- 43.6: `ContractHistoryEntry.title` + `.description` waren
|
||
auf beiden Pfaden ungestrippt – Admin konnte HTML/Script-
|
||
Tags einschreiben, Portal-User las sie roh zurück. Fix:
|
||
`stripHtml()` auf Create + Update (Write-Pfad) und
|
||
`sanitizeEntry()` im List + Get (Read-Pfad).
|
||
- 43.5: `stripHtml` ersetzt `javascript:` → `blocked:` –
|
||
sinnvoll bei URL-Feldern, hässlich in Tarif-/Preis-Namen.
|
||
Neuer `stripForDisplay`-Wrapper entfernt den Marker
|
||
zusätzlich in den Display-Feldern.
|
||
|
||
- [x] **🛡️ Pentest 42.5 (MEDIUM): priceFirst12Months XSS**
|
||
- Drei Preisfelder sind im Schema `String?` (freitextliche
|
||
Tarifangaben). `sanitizeContract` strippte sie auf dem
|
||
Read-Pfad nicht – Alt-Daten mit XSS-Payloads kamen 1:1
|
||
raus. Fix: `priceFirst12Months`, `priceFrom13Months`,
|
||
`priceAfter24Months` in `CONTRACT_DISPLAY_STRING_FIELDS`
|
||
aufgenommen.
|
||
|
||
- [x] **🛡️ Anzeige-Defense: HTML in display-relevanten Strings strippen**
|
||
- `sanitizeContract` und `sanitizeCustomer` strippen jetzt
|
||
zusätzlich HTML in den definierten Display-Feldern
|
||
(`providerName`, `tariffName`, `customerNumberAtProvider`,
|
||
`firstName`, `lastName`, `companyName`, etc.). Wirkt auch
|
||
auf nested `previousContract` + `energyDetails`. Damit
|
||
sehen Pentester-Payloads (`<script>`, `<img onerror>`)
|
||
in der Vertragsliste nicht mehr als hässlicher Klartext
|
||
aus, sondern verschwinden komplett.
|
||
|
||
- [x] **🆕 Vorvertrag-Verbrauch als Schätzwert im Folgevertrag**
|
||
- **ContractForm** (Strom/Gas): Wenn ein `previousContractId`
|
||
gesetzt ist, wird der Vorvertrag samt Readings nachgeladen und
|
||
der Verbrauch clientseitig über
|
||
`calculateMultiMeterConsumption` / `calculateConsumption`
|
||
berechnet. Unter dem Jahresverbrauch-Feld erscheint
|
||
`Vorvertrag: 1.698 kWh (hochgerechnet) [Übernehmen]` mit
|
||
Ein-Klick-Button, der den Wert ins Feld kopiert. Bei Gas
|
||
erscheint der Hinweis sowohl unter „Jahresverbrauch (m³)"
|
||
(mit m³-Wert) als auch unter „Jahresverbrauch (kWh)".
|
||
- **ContractDetail** (Strom/Gas): Wenn `annualConsumption` leer
|
||
ist und ein berechenbarer Vorvertrag existiert, wird die
|
||
Jahresverbrauch-Zelle stattdessen mit `~1.698 kWh` in blau
|
||
angezeigt, darunter klein „geschätzt aus Vorvertrag
|
||
(hochgerechnet)". Verschwindet automatisch, sobald der Wert
|
||
im Vertrag eingetragen ist.
|
||
- Funktioniert nur bei Verträgen mit explizitem `previousContract`
|
||
(Folgevertrag-Kette). Ohne Vorvertrag oder ohne genügend
|
||
Zählerstände kommt kein Hinweis.
|
||
|
||
- [x] **🆕 Endstand alter Zähler fließt in Verbrauchsberechnung ein**
|
||
- Bisher wurde der Wert „Letzter Stand alter Zähler" zwar als
|
||
`ContractMeter.finalReading` gespeichert, aber nirgends gelesen
|
||
– weder in der UI noch in `calculateMultiMeterConsumption`.
|
||
- Neuer Helper `recordPredecessorFinalReading(meterId, switchAt, value)`
|
||
in customer.service.ts: legt am Wechseldatum einen regulären
|
||
`MeterReading`-Eintrag für den Vorgänger an (Notes:
|
||
„Endstand bei Zählerwechsel"). Idempotent: existiert am
|
||
Wechseltag schon ein Reading, wird nichts geschrieben.
|
||
Validierung (monoton-steigend) wird vorab durchgeführt –
|
||
Konflikt führt zu sprechender 400-Fehlermeldung, ohne
|
||
halb-geschriebene Zustände zu hinterlassen.
|
||
- Wird aus beiden Pfaden aufgerufen: `addSuccessorMeter` im
|
||
contract.controller (Vertragsansicht → „Folgezähler hinzufügen")
|
||
und `createMeter` mit `successorOf` im customer.service
|
||
(Kundenakte → „Als Folgezähler deklarieren").
|
||
- Folge: Der Endstand erscheint jetzt in der Zählerstände-Liste
|
||
des Vorgänger-Zählers und fließt über
|
||
`calculateMultiMeterConsumption` automatisch in den Verbrauch
|
||
(Zeitraum bis `removedAt` ist inklusive).
|
||
- UI-Hinweise im Folgezähler-Form (Vertragsansicht + MeterModal)
|
||
erklären den neuen Effekt.
|
||
|
||
- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
|
||
- Bisher nur sichtbar im Multi-Meter-Zweig (`contractMeters.length > 0`)
|
||
– Folgeverträge ohne ContractMeter-Eintrag konnten so keinen
|
||
Folgezähler bekommen.
|
||
- Fix: Button wird jetzt aus dem if/else-Block gerendert, sobald
|
||
entweder ein Single-Meter (`energyDetails.meter`) oder
|
||
ContractMeter-Einträge vorhanden sind.
|
||
- Im Backend `addSuccessorMeter`: bei Single-Meter-Verträgen wird
|
||
der bisherige `energyDetails.meterId` automatisch als
|
||
ContractMeter (position 0, `removedAt` = Wechseldatum) backfillt,
|
||
damit der alte Zähler nicht aus der Vertragshistorie verschwindet.
|
||
|
||
- [x] **🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)**
|
||
- **Backend**: Neues Feld `Meter.predecessorMeterId`
|
||
(Self-Relation, `ON DELETE SET NULL`). Migration
|
||
`20260530140000_meter_predecessor` mit `IF NOT EXISTS`.
|
||
`createMeter` akzeptiert optional `successorOf: { predecessorMeterId,
|
||
installedAt?, finalReadingPrevious? }`. Wenn gesetzt: Vorgänger
|
||
wird validiert (gleicher Kunde + gleicher Typ), und für alle
|
||
Verträge, die den Vorgänger als aktuellen Zähler nutzen, wird
|
||
der ContractMeter-Eintrag analog zu `addSuccessorMeter`
|
||
propagiert (vorhandener ContractMeter wird `removedAt` +
|
||
`finalReading` gesetzt; neuer ContractMeter wird mit nächster
|
||
Position + `installedAt` angelegt; `energyDetails.meterId`
|
||
auf den Neuzähler aktualisiert). Idempotent gegen Doppel-Klick.
|
||
- **MeterModal** (Kundenakte → Zähler): Bei Neuanlage neue
|
||
Checkbox „Diesen Zähler als Folgezähler deklarieren". Wenn
|
||
aktiv: Dropdown Vorgänger-Zähler (alle Zähler des Kunden,
|
||
inkl. inaktive – mit Suffix), Wechseldatum (default heute),
|
||
Endstand alter Zähler (optional). Bei Vorgänger-Auswahl werden
|
||
Typ, Tarifmodell und Adresse vom Vorgänger übernommen und
|
||
disabled. Info-Banner: „Alle Verträge mit dem alten Zähler
|
||
werden automatisch umgestellt".
|
||
- Audit-Log: „Zähler angelegt als Folgezähler von X für Kunde #N".
|
||
|
||
- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
|
||
- In den Strom/Gas-Details neben der Zählernummer zusätzlich ein
|
||
rotes „Inaktiv"-Badge und eine Zeile mit Standort, falls
|
||
hinterlegt.
|
||
|
||
- [x] **🆕 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter**
|
||
- **Backend**: Neues Feld `Meter.addressId` (optional FK auf
|
||
`Address`, `ON DELETE SET NULL`). Migration
|
||
`20260530100000_meter_address` mit `IF NOT EXISTS`. Service
|
||
erzwingt beim Create: Lieferadresse muss vorhanden, zum Kunden
|
||
gehören und Typ `DELIVERY_RESIDENCE` haben.
|
||
- **MeterModal** (Kundenakte → Zähler): Pflicht-Dropdown
|
||
"Lieferadresse" über allen Feldern. Button "Zähler hinzufügen"
|
||
ist disabled, wenn keine Lieferadresse existiert – mit gelbem
|
||
Hinweis-Banner. Bestandszähler ohne Adresse zeigen im
|
||
MetersTab "Lieferadresse: nicht zugeordnet – bitte über
|
||
Bearbeiten nachpflegen" in gelb.
|
||
- **ContractForm** (Strom/Gas): Zähler-Dropdown filtert jetzt
|
||
auf die im Vertrag gewählte Lieferadresse. Deaktivierte
|
||
Zähler bleiben sichtbar (Label-Suffix `(deaktiviert)`); wenn
|
||
sie ausgewählt werden, kommt ein Toast: *„Deaktivierter
|
||
Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen
|
||
Altvertrag?"*. Platzhalter wechselt zwischen "Erst
|
||
Lieferadresse wählen…" / "Kein Zähler für diese Adresse
|
||
vorhanden" / "Zähler wählen…".
|
||
- Audit-Log loggt Adress-Änderung am Zähler als Feld
|
||
"Lieferadresse".
|
||
|
||
- [x] **🆕 Backup-Operations-Log + EBUSY-Fix beim Restore**
|
||
- Zwei neue Log-Panels auf der DB-Backup-Seite: links
|
||
"Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder
|
||
Eintrag zeigt ✓/✗-Status, Summary, Timestamp und User. Klick
|
||
öffnet ein Modal mit dem vollständigen Verlauf (alle
|
||
`console.log/error/warn/info`-Zeilen werden während der
|
||
Operation in einen Puffer mitgefangen).
|
||
- Persistiert in neuer Tabelle `BackupLog`
|
||
(Migration `20260519100000_backup_log` mit `IF NOT EXISTS`).
|
||
Limit 1 MB pro `fullLog`, Auto-Refresh alle 5s.
|
||
- Endpoints (settings:update):
|
||
`GET /api/settings/backup-logs?operation=CREATE|RESTORE`,
|
||
`GET /api/settings/backup-logs/:id`.
|
||
- **Bonus**: Das neue Log hat sofort einen alten Bug aufgedeckt –
|
||
`EBUSY: rmdir '/app/uploads'` beim Restore. Ursache: das
|
||
Backup-Service rief `deleteDirectory(UPLOADS_DIR)` mit dem
|
||
finalen `rmdirSync`, aber `/app/uploads` ist ein Bind-Mount,
|
||
den Linux nicht aushängen lässt. Fix: neuer Helper
|
||
`emptyDirectory()` löscht nur die Inhalte, das Verzeichnis
|
||
selbst bleibt stehen.
|
||
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
||
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
||
|
||
- [x] **🛡️ Pentest 2026-05-28 LOW 34.5: Backend-URL-Validierung für AppSettings**
|
||
- Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend.
|
||
API-Endpoint akzeptierte sonst `/relative/path`, `javascript:`,
|
||
`ftp://`, `http://192.168.1.1` etc. → Open-Redirect / SSRF-Vektor
|
||
in den an Kunden verschickten Portal-Mails.
|
||
- Neuer `validateSettingValue(key, value)` in appSetting.service.ts
|
||
mit per-Key-Logik: portalLoginUrl → nur http(s), absoluter Host,
|
||
`isBlockedSsrfHost`-Check, Trailing-Slash-Strip. Schwellenwerte
|
||
(deadline*/documentExpiry*) → positive Integer. Bool-Settings
|
||
→ strict `true`/`false`. monitoringAlertEmail → RFC-5322-light.
|
||
- Controller (updateSetting + updateSettings) ruft Validator nach
|
||
der HTML-Strip-Sanitisierung; bei Fehler 400 mit aussagekräftiger
|
||
Message. Bulk-PUT validiert ALLE Werte bevor irgendwas gespeichert
|
||
wird (kein halb-committed-State bei einem ungültigen Eintrag).
|
||
- **Live-verifiziert** auf dev:
|
||
- `/evil/path` → 400 "muss absolute http(s)-URL sein"
|
||
- `javascript:alert(1)` → 400 (durch stripHtml zu blocked: → Validator: unzulässiges Schema)
|
||
- `ftp://evil.com` / `data:text/html` → 400
|
||
- `http://169.254.169.254` → 400 (Cloud-Metadata immer geblockt)
|
||
- `http://192.168.1.1` → 200 (on-prem-Default; mit SSRF_BLOCK_PRIVATE_IPS=true → 400)
|
||
- `https://crm.example.de/` → DB: `https://crm.example.de` (Slash gestrippt)
|
||
- `https://crm.example.de//abc/` → DB: `https://crm.example.de//abc`
|
||
(nur trailing slash; doppelte slashes mittendrin bleiben)
|
||
|
||
- [x] **🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert**
|
||
- Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
|
||
`sanitizeContractBody`-Funktion lief auch über `portalPassword`.
|
||
Passwörter mit HTML-Pattern (`Pass<TAG>word!` → `Password!`) oder
|
||
URI-Schema-Prefix (`data:secret` → `blocked:secret`) wurden
|
||
irreparabel zerstört.
|
||
- Fix: `PASSTHROUGH_KEYS = {'portalPassword', 'password'}` – beim
|
||
Walk werden String-Werte unter diesen Keys NICHT durch
|
||
`stripHtml` geschickt. PW wird sowieso `encrypt()`-verschlüsselt
|
||
persistiert und niemals als HTML ausgegeben → kein XSS-Risk.
|
||
- Live-verifiziert: PW `MyP@ss<word>123!&data:foo` → byte-genau im
|
||
GET-Decrypt-Endpoint zurück. `providerName: <script>…` → weiter
|
||
auf `EvilProvider` gestrippt. `portalUsername: u<test>` → weiter
|
||
auf `u` gestrippt.
|
||
|
||
- [x] **🛡️ Pentest 2026-05-24 Pen-31-Befunde (2× MEDIUM)**
|
||
- **31.1 Stored XSS in Vertragsfeldern**: `providerName`, `tariffName`,
|
||
`priceFirst12Months`, `priceFrom13Months`, `priceAfter24Months`
|
||
nahmen rohe HTML/Script-Payloads an und lieferten sie 1:1 zurück.
|
||
Fix: rekursiver `sanitizeContractBody()` (Walk-and-Strip) im
|
||
contract.controller wird auf `req.body` von POST + PUT
|
||
angewandt. Nutzt das bestehende `stripHtml()` aus utils/sanitize,
|
||
inkl. URI-Schema-Block + Entity-Decode. Verträge enthalten keine
|
||
legitimen HTML-Felder (Editor-HTML lebt in AppSettings), daher
|
||
Strip ohne Risiko. Audit-Vergleich nutzt jetzt die sanitisierte
|
||
Version, sonst Audit ↔ DB-Drift.
|
||
- **31.2 IDOR auf `GET /customers/:id/stressfrei-emails`** (und 4
|
||
weiteren Routes mit `requireCustomerAccess`): das Middleware
|
||
short-circuitete auf `customers:read` – aber Portal-User haben
|
||
diese Perm im JWT (für eigene Daten). Damit kam Portal-Kunde 1
|
||
an IMAP-Konten/Adressen/Bank-Cards/Documents/Meters von
|
||
Kunde 3. Fix in `middleware/auth.ts:requireCustomerAccess`:
|
||
erst `isCustomerPortal`-Check (eigene + vertretene IDs), DANN
|
||
erst Perm-Check für Mitarbeiter. Damit sind alle 6 Routes
|
||
mit einem Middleware-Patch dicht. Defense-in-Depth: in
|
||
`stressfreiEmail.controller.getEmailsByCustomer` zusätzlich
|
||
`canAccessCustomer`-Call analog zum POST-Handler.
|
||
- **Infos** (keine Code-Änderung):
|
||
- `type:"STROM"` ist deprecated – richtige Enum ist `ELECTRICITY`.
|
||
- HSTS auf Staging fehlt: HSTS macht der nginx-Reverse-Proxy,
|
||
Backend setzt's bewusst nicht (Doppel-Header-Vermeidung).
|
||
Auf Staging muss der Proxy-Op das HSTS-Header-Add aktivieren.
|
||
- Portal-Login-Rate-Limit 5 vs 10: Env-Drift, identische Codebase.
|
||
- **Live-verifiziert** auf dev:
|
||
- Portal-User 1 vs Customer 3: alle 6 Routes 403
|
||
(`/customers/3`, `.../addresses`, `.../bank-cards`,
|
||
`.../documents`, `.../meters`, `.../stressfrei-emails`).
|
||
- XSS-Payloads `<script>`, `<svg/onload>`, `<img onerror>`,
|
||
`javascript:`, `<script>` in 5 Vertragsfeldern →
|
||
DB-Werte bereinigt (`EvilProvider`, `blocked:alert(4) 35€` etc.).
|
||
|
||
- [x] **🆕 Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Doppel-Input**
|
||
- **Info-Icon neben Kundennamen** öffnet ein Modal mit den
|
||
wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
|
||
Gründungsdatum, primäre Adresse, Telefon, Mobil, E-Mail,
|
||
Portal-E-Mail, Steuer-/Handelsregister-Nr.). Jedes Feld hat
|
||
einen Copy-Button (bestehende `CopyableValue`-Komponente).
|
||
Neue Komponente: `CustomerInfoModal.tsx`. Lazy-Fetch via
|
||
`customerApi.getById`, staleTime 30s.
|
||
- **Cent/Euro-Doppel-Input** für Arbeitspreise (Strom + Gas):
|
||
Neben dem €/kWh-Feld jetzt ein zweites Feld ct/kWh. Bidirektional
|
||
verkoppelt – Tippen in € aktualisiert ct (×100), Tippen in ct
|
||
aktualisiert € (÷100). Im Backend wird unverändert nur der
|
||
Euro-Wert persistiert; Cent ist reine UI-Hilfe. Float-Rausch-
|
||
Schutz (Math.round × 1e6) verhindert "0.25 → 25.0000000000…".
|
||
Greift für `unitPrice` und (bei DUAL-Zählern) `unitPriceNt`.
|
||
|
||
- [x] **🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)**
|
||
- Bisher gab es ein einzelnes `bonus`-Feld auf `EnergyContractDetails`.
|
||
Jetzt zwei Felder `instantBonus` (Sofort) und `newCustomerBonus`
|
||
(Neukunden), die zusammen den Gesamtbonus ergeben.
|
||
- **Migration** `20260524100000_split_energy_bonus`:
|
||
`ADD COLUMN IF NOT EXISTS instantBonus`, `... newCustomerBonus`,
|
||
`UPDATE` kopiert bestehendes `bonus` → `instantBonus`
|
||
(Annahme: Bestandsverträge hatten primär Sofort-Bonus), dann
|
||
`DROP COLUMN IF EXISTS bonus`. Idempotent.
|
||
- **Form** (`ContractForm.tsx`): zwei Input-Felder „Sofort-Bonus (€)"
|
||
+ „Neukunden-Bonus (€)" statt einem.
|
||
- **Detail-Ansicht**: zeigt beide Felder einzeln und einen
|
||
aggregierten „Gesamtbonus" (fett) darunter.
|
||
- **Kostenvorschau**: listet Sofort + Neukunden einzeln auf
|
||
(jeweils grün), dann „Gesamtbonus" (mittel grün, fett), danach
|
||
Effektive Jahreskosten.
|
||
- **Cost-Calc** (`energyCalculations.ts`): `calculateCosts()` nimmt
|
||
jetzt beide Bonus-Werte; `CostCalculation` liefert `instantBonus`,
|
||
`newCustomerBonus` + `totalBonus`.
|
||
- **PDF-Template-Variablen**: drei neue Placeholder
|
||
`energyDetails.instantBonus`, `.newCustomerBonus`, `.totalBonus`
|
||
(alter `.bonus`-Placeholder entfernt).
|
||
- **Audit-Log** unterscheidet jetzt beide Felder.
|
||
- **Live-verifiziert** auf dev: PUT mit beiden Feldern → DB hat
|
||
`instantBonus=75, newCustomerBonus=125`, GET liefert beide
|
||
Werte zurück.
|
||
|
||
- [x] **🛡️ Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)**
|
||
- **30.13 MIME-Extension-XSS** (MEDIUM): `GET /api/files/download`
|
||
lieferte hochgeladene Dateien via `res.sendFile` aus. Da multer
|
||
nur den client-gemeldeten MIME prüft, konnte eine als
|
||
`application/pdf` deklarierte `.html`-Datei auf Disk landen –
|
||
Express bestimmt beim Senden den Content-Type aus der Extension
|
||
(`.html` → `text/html`) und der Browser hätte gerendert. Stored
|
||
XSS für eingeloggte Empfänger. Fix: `Content-Disposition:
|
||
attachment; filename=<safe>` + bestehendes `X-Content-Type-
|
||
Options: nosniff`. Browser lädt jetzt herunter statt zu rendern,
|
||
selbst wenn der Type stimmt. Filename wird auf
|
||
`[A-Za-z0-9._-]` gesäubert.
|
||
- **30.14 SSRF Private-IP-Block opt-in** (INFO): Neuer Env-Flag
|
||
`SSRF_BLOCK_PRIVATE_IPS=true` erweitert die SSRF-Block-Liste auf
|
||
127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1,
|
||
fc00::/7 + IPv4-mapped Varianten + Hostnamen "localhost"/
|
||
"ip6-localhost". Default off, damit On-Prem-Installationen
|
||
(Plesk/Dovecot auf 127.0.0.1) nicht brechen. Cloud-Deployments
|
||
setzen den Flag.
|
||
- **Live-verifiziert** auf dev:
|
||
- Upload + Download: Header zeigt
|
||
`Content-Disposition: attachment; filename="…"` +
|
||
`X-Content-Type-Options: nosniff`
|
||
- Default-ssrfGuard: 127.0.0.1 / 10.x / 192.168.x / localhost → false
|
||
(durchgelassen für on-prem); 169.254.169.254 → true (Cloud-
|
||
Metadata weiter geblockt)
|
||
- Mit `SSRF_BLOCK_PRIVATE_IPS=true`: alle privaten Ranges → true;
|
||
8.8.8.8 (legit public) → false
|
||
|
||
- [x] **🛡️ Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)**
|
||
- **28.1 Restarbeit**: `DANGEROUS_URI_SCHEMES` jetzt vollständig –
|
||
`blob:`, `about:`, `ws:`, `wss:`, `ldap:`, `dict:` ergänzt. Bewusst
|
||
NICHT geblockt: `http(s):`, `mailto:`, `tel:` (legitime URLs in
|
||
Notizfeldern).
|
||
- **29.1 Cyrillic-Homoglyph**: `jаvascript:` mit kyrillischem
|
||
`а` (U+0430) lief vorher durch. `HOMOGLYPH_TO_ASCII`-Map mit den
|
||
13 üblichen Spoofing-Buchstaben (а→a, е→e, о→o, …) wird VOR dem
|
||
Scheme-Strip angewendet. Legitime Buchstaben außerhalb der Map
|
||
bleiben unangetastet.
|
||
- **29.2 Percent-Encoding**: `java%73cript:` umging die Regex.
|
||
`percentDecode()` läuft jetzt iterativ bis zu 5 Runden (fängt
|
||
auch `java%2573cript:` ab, das zuerst zu `java%73cript:` und
|
||
dann zu `javascript:` wird).
|
||
- **29.3 Zero-Width-Joiner**: `javascript:` mit U+200B/200C/200D
|
||
etc. wird durch `ZERO_WIDTH_CHARS`-Regex entfernt, bevor irgendein
|
||
Match läuft.
|
||
- **28.3 Partial – PDF-Validierung tiefer**: nicht nur Magic-Bytes,
|
||
sondern auch (1) `%%EOF`-Marker in den letzten 1 KB und (2)
|
||
Pattern-Scan der ersten 4 KB auf `#!/`, `<script`, `<?php`, `<%`,
|
||
`MZ ` (PE-Header). Dadurch wird `%PDF-1.4\n#!/bin/bash` jetzt
|
||
sauber abgelehnt. Voll-PDF-Parsing wäre überzogen – die simplen
|
||
Spoofs sind weg.
|
||
- **29.4 Email-Format-Validator**: neuer `isValidEmail()` in
|
||
sanitize.ts lehnt Whitespace/Newlines/Tabs (= SMTP-Header-
|
||
Injection-Vektor wie `test@x.de\nBcc:attacker@…`) sowie Format-
|
||
Müll wie `notanemail` ab. Verdrahtet in
|
||
`createCustomer`/`updateCustomer`/`updatePortalSettings`/
|
||
`createUser`/`updateUser`. Liefert 400.
|
||
- **29.5 GET /api/providers/email 500 → 404**: `parseInt("email")`
|
||
war `NaN`, Prisma-Query crashte. Controller validiert jetzt
|
||
`Number.isFinite(id) && id ≥ 1` und liefert 404 ("Anbieter nicht
|
||
gefunden"). Numerische IDs funktionieren weiter.
|
||
- **Live-verifiziert** auf dev:
|
||
- blob:/about:/ws:/wss:/ldap:/dict: alle → `blocked:`
|
||
- `jаvascript:alert(1)` (cyr) → `blocked:alert(1)`
|
||
- `java%73cript:alert(1)` → `blocked:alert(1)`
|
||
- `java%2573cript:alert(1)` → `blocked:alert(1)`
|
||
- ZWJ-Variante → `blocked:alert(1)`
|
||
- `%PDF-1.4\n#!/bin/bash` → 400 + Datei gelöscht
|
||
- PDF ohne `%%EOF` → 400
|
||
- Echtes PDF mit `%%EOF` → 200
|
||
- `email: "notanemail"` → 400
|
||
- `email: "test@x.de\nBcc:..."` → 400
|
||
- `GET /providers/email` → 404 (war 500)
|
||
- `GET /providers/<valid_id>` → 200
|
||
|
||
- [x] **🛡️ Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)**
|
||
- **28.1 URI-Schema unvollständig**: `DANGEROUS_URI_SCHEMES`-Regex
|
||
erweitert um `file:` und `ftp:`. `ftp://evil.com/x.js` und
|
||
`file:///etc/passwd` in companyName werden jetzt zu
|
||
`blocked://...` bzw. `blocked:///...` neutralisiert.
|
||
- **28.2 HTML-Entity-Decoding-Bypass**: `stripHtml` lief vorher
|
||
direkt über den Roh-String, sodass `javascript:`,
|
||
`<script>` und `<script>` an der Regex
|
||
vorbeischlüpften. Neuer `decodeHtmlEntities()` läuft VOR dem
|
||
Strip und dekodiert numerische (`&#NN;`/`&#xHH;`) + gängige
|
||
Named-Entities (lt/gt/quot/apos/amp). Danach greift die normale
|
||
Tag- und URI-Säuberung wieder.
|
||
- **28.3 Vollmacht-Upload Magic-Byte-Check**: multer prüfte nur
|
||
den client-gemeldeten MIME-Type, eine HTML/PHP-Datei als
|
||
`application/pdf` kam durch. Neuer Check liest die ersten 5
|
||
Bytes nach dem Upload und verlangt `%PDF-` – sonst wird die
|
||
Datei gelöscht und 400 geliefert. Greift bevor irgendwas in
|
||
der DB landet.
|
||
- **28.4 Rate-Limit auf /api/public/consent**: 30 Requests pro
|
||
IP pro 15 Minuten. Brute-Force-sicher war der 128-Bit-UUID-Hash
|
||
schon, aber ohne Limit konnte ein Angreifer das System per
|
||
POST-Spam mit Audit-Logs und Mail-Versand belasten. Neuer
|
||
`publicConsentRateLimiter` greift jetzt auf alle drei
|
||
Sub-Routes (`/:hash`, `/:hash/grant`, `/:hash/pdf`).
|
||
- **Live-verifiziert** auf dev:
|
||
- `ftp://evil.com/x.js` → `blocked://evil.com/x.js`
|
||
- `file:///etc/passwd` → `blocked:///etc/passwd`
|
||
- `javascript:alert(1)` → `blocked:alert(1)`
|
||
- `<script>alert(1)</script>OK` → `OK`
|
||
- `<script>bad()</script>Legit` → `Legit`
|
||
- HTML-Datei als PDF hochgeladen → 400 + Datei gelöscht
|
||
- Echtes PDF (Magic-Bytes ok) → 200
|
||
- 35× POST auf public-consent → Req 1–30 = 404, Req 31+ = 429
|
||
|
||
- [x] **🧹 Pentest 2026-05-20 LOW/INFO-Sammelfix**
|
||
- **27.1 Path-Traversal-Strings in DB**: `cleanupConsents` validierte
|
||
`documentPath` zuvor nur per stripHtml, was `../../../etc/passwd`
|
||
durchließ (kein File-Read, aber dreckige Datenbasis). Neuer
|
||
`isValidDocumentPath`-Check akzeptiert nur `/?uploads/<safe>`,
|
||
alles andere wird auf `NULL` gesetzt.
|
||
- Generischer `cleanupDocumentPaths`-Pass über die fünf weiteren
|
||
Tabellen mit `documentPath` (BankCard, IdentityDocument, Invoice,
|
||
RepresentativeAuthorization als nullable; ContractDocument
|
||
NOT NULL → wird nur berichtet, manuell entscheiden).
|
||
- **Orphaned User**: Neuer Report-Step `reportOrphanedUsers` warnt
|
||
beim Container-Start vor User ohne Rollenzuordnung (= im
|
||
Permission-System unsichtbar). Löschen tut das Skript nicht
|
||
(false-positive-Risiko bei legitimen Spezial-Usern).
|
||
- **Seed-PW-Policy**: `generateInitialPassword()` nutzte
|
||
`Math.random()` (vorhersagbar). Jetzt `crypto.randomInt()` für
|
||
Pick + Shuffle, 28 Zeichen aus 4 Klassen.
|
||
- **`PUT /users/:id` mit `permissions` / `password`**: vorher
|
||
silent-drop durch Whitelist + HTTP 200. Jetzt explizit HTTP 400
|
||
mit Hinweis auf den dedizierten `/password`-Endpoint bzw. die
|
||
Role-Steuerung. Gleicher Pattern wie `PUT /portal` für password.
|
||
- **`/api/health` ohne Auth**: BEWUSST so – Container-Healthcheck +
|
||
Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur
|
||
`{status,timestamp}` – keine Version, kein DB-Status, kein
|
||
Hostname → kein Info-Leak. Kommentar im Code dokumentiert die
|
||
Entscheidung.
|
||
- **Live-verifiziert** auf dev:
|
||
- CustomerConsent.documentPath=`../../../etc/passwd` → NULL
|
||
- PUT /users mit `permissions` → 400 mit klarer Message
|
||
- PUT /users mit `password` → 400 mit Hinweis auf /password
|
||
- Orphan-User angelegt → vom Cleanup-Lauf gemeldet
|
||
- `crypto.randomInt`-Pfad rauscht durch ohne Fehler
|
||
|
||
- [x] **🛡️ Pentest 2026-05-20 MEDIUM+LOW: Consent + URI-Sanitization**
|
||
- **MEDIUM Consent-Mass-Assignment**: PUT `/api/gdpr/customer/:id/consents/:type`
|
||
nahm `source`, `documentPath`, `version` ungefiltert aus dem Body
|
||
– Portal-User konnte `source: "ADMIN_OVERRIDE"`, `version:
|
||
"<script>"` oder `documentPath: "../../etc/passwd"` durchschmuggeln.
|
||
Fix: nur noch `status` aus Body, source server-seitig hardcoded
|
||
auf `'portal'`, documentPath/version bleiben NULL (werden vom
|
||
dedizierten Authorization-Upload-Endpoint server-seitig gesetzt).
|
||
- Whitelist für `CustomerConsent.source` ergänzt
|
||
(`portal | public-link | telefon | papier | email | crm-backend`).
|
||
`grantAuthorization` (Admin) erzwingt sie ebenfalls; `notes`
|
||
läuft jetzt durch `stripHtml`.
|
||
- **LOW javascript:-URI**: `stripHtml()` filtert jetzt zusätzlich
|
||
`javascript:`, `data:`, `vbscript:` – ersetzt durch `blocked:`,
|
||
damit `<a href={companyName}>` nichts feuert. Verträglich mit
|
||
legitimem Text, der nicht "javascript:" enthält.
|
||
- Cleanup-Skript erweitert um `cleanupConsents()`:
|
||
Whitelist-Reset für source, stripHtml für version/documentPath
|
||
– idempotent, läuft beim Container-Start automatisch.
|
||
- **Live-verifiziert** auf dev:
|
||
- PUT mit `{status:"GRANTED",source:"ADMIN_OVERRIDE",
|
||
version:"<script>",documentPath:"../../etc/passwd"}` →
|
||
DB hat `source=portal, documentPath=NULL, version=NULL`.
|
||
- companyName `javascript:alert(1)` → `blocked:alert(1)`.
|
||
- companyName `<a href=data:text/html,<script>...>Click</a>` → `Click`.
|
||
- Cleanup auf dirty DB: source `ADMIN_OVERRIDE` → `unknown`,
|
||
documentPath/version gesäubert.
|
||
|
||
- [x] **🚨 Pentest 2026-05-20 KRITISCH: Backup-Restore ohne Confirm-Body**
|
||
- `POST /api/settings/backup/:name/restore` startete bei leerem
|
||
Body sofort den destruktiven Restore. Im Unterschied zu
|
||
`/factory-reset` fehlte der Magic-String-Confirm-Check. Risiko:
|
||
versehentlicher Re-Fire (Doppelklick, Browser-Replay, eingeloggter
|
||
Admin auf bösartiger Drittseite) überschrieb stillschweigend die
|
||
komplette DB.
|
||
- Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
|
||
`{ "confirm": "RESTORE-BESTAETIGT" }` enthalten, sonst 400.
|
||
Frontend-Client schickt den String beim Klick im Bestätigungs-
|
||
Dialog automatisch (kein UX-Change für den User).
|
||
- **Live-verifiziert** auf dev:
|
||
- leerer Body → 400 "Bestätigung fehlt"
|
||
- `{"confirm":"ja"}` → 400 (wrong)
|
||
- `{"confirm":"RESTORE-BESTAETIGT"}` → 200, Restore lief
|
||
|
||
- [x] **🛡️ XSS-Sanitization für Plain-Text-AppSettings (Pentest MEDIUM)**
|
||
- `companyName` (und weitere Plain-Text-Keys wie `defaultEmailDomain`,
|
||
`monitoringAlertEmail`, Schwellenwerte) konnten via PUT
|
||
`/api/settings/:key` mit XSS-Payloads befüllt werden – das war
|
||
nur Admin-triggerbar, aber E-Mail-Templates/PDF-Generatoren
|
||
hätten den Wert ungescaped rendern können.
|
||
- Fix: neuer `sanitizeSettingValue(key, value)` in
|
||
`appSetting.service.ts` strippt HTML außer für die expliziten
|
||
HTML-Editor-Keys (`imprintHtml`, `privacyPolicyHtml`,
|
||
`authorizationTemplateHtml`, `websitePrivacyPolicyHtml`).
|
||
Greift in `updateSetting` (Einzel) und `updateSettings` (Bulk).
|
||
- Cleanup-Skript erweitert: bestehende AppSettings mit HTML in
|
||
Plain-Text-Keys werden beim Container-Start gestrippt
|
||
(idempotent).
|
||
- **Live-verifiziert** auf dev: `<img src=x onerror=alert(1)>OpenCRM
|
||
<script>alert(2)</script>` via PUT → DB-Wert: `"OpenCRM"`.
|
||
`imprintHtml` mit `<h1><p>` → unverändert.
|
||
|
||
- [x] **🐛 Rollen-Perms-Sync beim Container-Start (Follow-up DSGVO-Fix)**
|
||
- Bestehende Installationen liefen weiter mit veraltetem
|
||
Permission-Set für die DSGVO-Rolle (audit:read u.a. fehlten),
|
||
weil `prisma db seed` per docker-entrypoint nur auf leeren DBs
|
||
läuft. Folge: Settings.tsx-Fix vom Vorgänger-Commit half nicht,
|
||
weil das JWT die fehlende Perm gar nicht enthielt.
|
||
- Neuer Step im Entrypoint: `npx tsx prisma/sync-roles.ts` läuft
|
||
bei jedem Start. Idempotent, fasst nur Permission- und
|
||
Role-Tabellen an (keine User/Customers/Contracts), führt
|
||
`syncRolePermissions` für Admin, Developer, DSGVO,
|
||
Mitarbeiter, Mitarbeiter (Nur-Lesen), Kunde aus.
|
||
- Live-verifiziert: `audit:read` aus DSGVO-Rolle gelöscht, Script
|
||
laufen lassen → "+1 Permission an Rolle #27", wieder vollständig.
|
||
|
||
- [x] **🐛 DSGVO-Rolle: Menüpunkte in den Einstellungen unsichtbar**
|
||
- Symptom: User mit ausschließlich DSGVO-Rolle sah keinerlei
|
||
Karten unter Einstellungen → System (DSGVO-Dashboard,
|
||
Datenschutzerklärung, Vollmacht-Vorlage, Impressum,
|
||
Website-Datenschutz, E-Mail-Versandlog, Audit-Protokoll).
|
||
- Ursache: Der gesamte System-Block in `frontend/src/pages/Settings.tsx`
|
||
war in `hasPermission('settings:update')` eingewickelt. DSGVO
|
||
hat aber nur `audit:*` und `gdpr:*` Perms – kein `settings:update`.
|
||
- Fix: Outer-Check erweitert auf
|
||
`settings:update || audit:read || gdpr:admin`. Jede Karte hat
|
||
weiterhin ihren eigenen feingranularen Check; für DSGVO-User
|
||
erscheinen nur die DSGVO-/Audit-Karten.
|
||
- Backend-API mit reiner DSGVO-Rolle (kein Admin) live durchgetestet:
|
||
`/api/gdpr/dashboard`, `/api/audit-logs`, `/api/email-logs`,
|
||
`/api/gdpr/privacy-policy`, `/api/gdpr/authorization-template`,
|
||
`/api/gdpr/imprint`, `/api/gdpr/website-privacy-policy`,
|
||
`/api/gdpr/consents/overview`, `/api/gdpr/deletions` → alle 200.
|
||
Backend war nicht das Problem.
|
||
|
||
- [x] **🛡️ Login-Rate-Limit jetzt pro (IP + Email)-Tupel**
|
||
- Vorher reine IP-basierte Sperre, was zwei Schwächen hatte:
|
||
a) Familie hinter NAT: Max vertippt sich → Nina kommt nicht rein
|
||
b) Angreifer wechselt Proxy → wieder 10 freie Versuche pro
|
||
Account, dieselbe IP-only-Sperre umgangen.
|
||
- Eine reine Email-Sperre wurde verworfen wegen Account-Lockout-
|
||
DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP-
|
||
Problem.
|
||
- **Lösung**: Bucket-Key ist `${ip}|${email-lowercase}`. Damit:
|
||
* Max von IP-A 10x vergeigt → (IP-A, max) gesperrt
|
||
* Nina von IP-A → eigenes Bucket (IP-A, nina), unbetroffen
|
||
* Admin von IP-A mit richtigem PW → erfolgreicher Login
|
||
* Max von IP-B → eigenes Bucket (IP-B, max), darf wieder
|
||
- Implementation: `loginRateLimiter.keyGenerator = ${ip}|${email}`
|
||
in `middleware/rateLimit.ts`; nur ein Limiter, kein zusätzlicher
|
||
Email-only.
|
||
- Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt
|
||
beides mit, Audit-Log resourceId = `${ip}|${email}`.
|
||
- **Live-verifiziert** (4 Schritte):
|
||
11x falsch max → 429, Nina/Admin von gleicher IP → durch,
|
||
max bleibt gesperrt, Reset → max wieder 401.
|
||
|
||
- [x] **🚨 PUT /customers/:id/portal mit `password` im Body → 400**
|
||
- Endpoint nahm `password` silent entgegen, ignorierte es, gab
|
||
aber HTTP 200 zurück → Client glaubte fälschlich, das Passwort
|
||
sei gesetzt. Fix: explizite Body-Validierung – `password`,
|
||
`portalPassword`, `portalPasswordHash`, `portalPasswordEncrypted`
|
||
sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten
|
||
`POST /portal/password`-Endpoint.
|
||
|
||
- [x] **🚨 Pentest Runde 17 – JWT-TTL + Pentest-Marker-Detection**
|
||
- **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
|
||
die noch die alte Konvention vor der Refresh-Token-Trennung
|
||
hatten (`JWT_EXPIRES_IN=7d`). docker-compose.yml und
|
||
`.env.example` standen schon richtig auf 15m als Default.
|
||
Alle `.env`-Files (Root, backend/, docker/.env.example,
|
||
backend/.env.example) jetzt auf `JWT_EXPIRES_IN=15m` mit
|
||
explizitem `JWT_REFRESH_EXPIRES_IN=7d`. Auf prod kann der
|
||
Container mit dem neuen Default neu hochgezogen werden.
|
||
- **17.5 Alte Pentest-Daten in DB**: das Cleanup-Script läuft
|
||
schon bei jedem Container-Start, strippt HTML aus Customer/
|
||
User-Strings und entfernt nicht-whitelisted AppSettings. Es
|
||
erkannte aber keine Test-Records ohne HTML (z.B. Customer mit
|
||
`email: hacker@evil.de`). Erweiterung:
|
||
* Neue Marker-Pattern-Liste: `^hacker@`, `^attacker@`,
|
||
`^pentest@`, `@evil\.`, `<script\b`, `onerror=`,
|
||
`javascript:`, SQL-Injection + Path-Traversal-Pattern.
|
||
* Bewusst eng: nur EMail-Adressen die mit dem Marker BEGINNEN,
|
||
damit legitime Kunden mit "hacker" o.ä. im Nachnamen
|
||
(z.B. `stefanhacker@gmx.de`) NICHT als Pentest-Marker
|
||
durchgehen.
|
||
* Default-Verhalten: nur warnen + Aufzählen. Mit
|
||
`CLEANUP_PURGE_PENTEST=true`-ENV werden die markierten
|
||
Customer/User-Records gelöscht.
|
||
* Live-verifiziert: `stefanhacker@gmx.de` (echt) → durch;
|
||
`hacker@evil.de` (Test) → erkannt + Warnung; mit Purge-Env
|
||
→ gelöscht.
|
||
- **18.4 Klartext-Portal-PW-Abruf**: BEWUSST DRIN GELASSEN auf
|
||
Wunsch (Admin-UI-Komfort). Klartext bleibt für Admin via
|
||
`GET /customers/:id/portal/password` abrufbar; ist mit
|
||
`customers:update`-Permission gated und mit Audit-Log
|
||
(`READ → PortalPassword`) auditiert.
|
||
|
||
- [x] **🚨 Pentest Runde 15 – KRITISCH: portalPasswordHash in PUT/POST-Response**
|
||
- **20.3 KRITISCH**: `PUT /customers/:id` gab den vollen bcrypt-Hash
|
||
(`$2a$12$…`) zurück, weil `updateCustomer` Service-Output ohne
|
||
sanitize-Aufruf direkt durchreichte. GET-Endpoints waren dicht,
|
||
die Update-Response nicht. **20.4 HOCH** gleicher Klasse:
|
||
`portalPasswordResetToken`, `consentHash`,
|
||
`portalPasswordMustChange`, `portalTokenInvalidatedAt` leakten
|
||
ebenfalls über PUT/POST.
|
||
- **Fix**:
|
||
* `updateCustomer` + `createCustomer` rufen jetzt
|
||
`sanitizeCustomer`/`sanitizeCustomerStrict` auf den Service-
|
||
Output (je nach `customers:update`-Permission).
|
||
* `updateContract` + `createContract` + `createFollowUp` +
|
||
`createRenewal` analog mit `sanitizeContract`/Strict
|
||
(Portal-Hint via `req.user.isCustomerPortal`).
|
||
* `portalPasswordMustChange` und `portalTokenInvalidatedAt`
|
||
zusätzlich von `PORTAL_HIDDEN_CUSTOMER_FIELDS` zu
|
||
`SENSITIVE_CUSTOMER_FIELDS` hochgezogen – damit greift der
|
||
Schutz auch bei der normalen `sanitizeCustomer`-Variante
|
||
(Admin-Sicht). Auch Pentester-Empfehlung in HOCH-Klasse.
|
||
- **Live-verifiziert**:
|
||
* Admin `PUT /customers/3 {firstName:…}` → 0 Leaks bei
|
||
portalPasswordHash/ResetToken/Expires/MustChange/consentHash/
|
||
TokenInvalidatedAt; `portalPasswordEncrypted` bleibt für
|
||
Admin sichtbar (UI-Workflow)
|
||
* Portal-User `GET /customers/3` → 0 Leaks auch bei
|
||
portalPasswordEncrypted/notes
|
||
* `POST /customers` (create) ebenfalls dicht
|
||
|
||
- [x] **🛟 Admin-Rescue-Script (PW-Reset direkt in DB + Rate-Limit-Reset)**
|
||
- Use Case: Admin sperrt sich aus (z.B. `admin@admin.com` ist
|
||
keine echte E-Mail → Passwort-vergessen-Flow kann keine Mail
|
||
senden) oder Brute-Force-Lockout will sich nicht auflösen.
|
||
- **Node-Script** `backend/prisma/reset-admin-password.ts`:
|
||
findet User per Email, hasht PW mit bcrypt cost 12, schreibt
|
||
direkt in `user.password`, setzt `tokenInvalidatedAt = now()`
|
||
(kickt alle bestehenden Sessions), löscht ggf. anhängende
|
||
Reset-Tokens. Mit oder ohne PW-Argument (random 28-char wenn
|
||
leer), Komplexitäts-Check 25 Zeichen für eigene PWs.
|
||
- **Bash-Wrapper** `scripts/admin-rescue.sh`:
|
||
* `password <email> [pw]` – PW-Reset im laufenden Container
|
||
via `docker exec opencrm-app npx tsx prisma/reset-admin-…`
|
||
* `unlock` – Container-Restart leert den In-Memory-Rate-Limit-
|
||
Store komplett (alle IPs frei)
|
||
* `all <email> [pw]` – beides
|
||
- **Live-verifiziert**: random-Modus liefert 28-char PW, schwaches
|
||
eigenes PW wird mit allen Defizit-Punkten abgelehnt, langes
|
||
eigenes PW akzeptiert, unbekannter User → klarer Fehler,
|
||
bash-Syntax-Check ok.
|
||
|
||
- [x] **🔐 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)**
|
||
- 12 Zeichen sind heute der untere akzeptable Rand. NIST/OWASP/BSI
|
||
empfehlen 14-25+ Zeichen. Mitarbeiter/Admin nutzen Passwort-Manager
|
||
→ Länge kostet nichts, Sicherheitsgewinn ist real.
|
||
- **Schwellwerte**: `STAFF_MIN_PASSWORD_LENGTH = 25`,
|
||
`PORTAL_MIN_PASSWORD_LENGTH = 12` (Endkunden tippen das auch auf
|
||
dem Handy ein).
|
||
- **Backend-Pfade**:
|
||
* `createUser` + `register` + `setUserPassword` → 25 Zeichen
|
||
* `setPortalPassword` + `changeInitialPortalPassword` → bleibt 12
|
||
* `confirmPasswordReset`: Server bestimmt Audience anhand des
|
||
Tokens (`getPasswordResetAudience`) → User-Token = 25, Customer-
|
||
Token = 12. Damit kann ein Angreifer nicht durch Body-Hint
|
||
auf den schwächeren Schwellwert ausweichen.
|
||
- **Seed-Admin**: Default-Passwort jetzt 28-char Zufallspasswort
|
||
(alle 4 Klassen garantiert), via `SEED_ADMIN_PASSWORD`-ENV
|
||
überschreibbar – aber nur wenn ≥ 25 Zeichen, sonst ignoriert
|
||
mit Log-Warnung.
|
||
- **Frontend**:
|
||
* UserList: Hinweis-Text "Mind. 25 Zeichen". Update + Passwort
|
||
gleichzeitig → Frontend macht jetzt zwei Calls (PUT + neuer
|
||
`POST /users/:id/password`) statt Passwort durch Update-Body
|
||
durchzuschmuggeln.
|
||
* PasswordResetConfirm: Hinweis "Mind. 12 Zeichen (Mitarbeiter:
|
||
25)", Server entscheidet endgültig.
|
||
* `userApi.setPassword(id, password)` neu in api.ts.
|
||
- **Live-verifiziert**:
|
||
* `POST /users/6/password "Hallo123!Test"` (12 chars) → 400
|
||
"mindestens 25 Zeichen"
|
||
* `POST /users/6/password "MeinExtremLangesPW2026!Test"` → 200,
|
||
Login mit dem neuen PW → success
|
||
* `POST /customers/3/portal/password "Hallo123!Test"` (12) → 200
|
||
* `POST /users {…,password:"Hallo123!Test"}` → 400 (25-char-Floor)
|
||
- **Nächster größerer Sprung** wäre **MFA für Mitarbeiter-Login**
|
||
(TOTP via Authenticator). Eigenes Thema, separate Aufgabe.
|
||
|
||
- [x] **🚨 Pentest Runde 12 – Folge-Fixes: XSS-Reste, User-PW-Endpoint, JS-Error-Leak, Seed-PW**
|
||
- **M2-Reste (XSS-Strings noch in DB)**: neues idempotentes Script
|
||
`prisma/cleanup-xss-and-mass-assignment.ts` läuft beim
|
||
Container-Start. Strippt HTML aus Customer/User-String-Feldern;
|
||
entfernt AppSettings, deren Key nicht in `ALLOWED_SETTING_KEYS`
|
||
steht. Mehrfacher Aufruf ändert nichts.
|
||
- **User-Update akzeptierte `password`-Feld**: stillschweigend ohne
|
||
dedizierten Audit-Eintrag. Jetzt: `password` aus
|
||
`USER_UPDATABLE_FIELDS` raus (CREATE behält es weiterhin); neuer
|
||
Endpoint `POST /api/users/:id/password` mit eigenem Audit-Log
|
||
"Passwort … durch Admin gesetzt", Komplexitäts-Check inklusive.
|
||
- **JS-Runtime-Fehler leakten weiter**: ORM-Leak-Patterns erweitert
|
||
um `TypeError`, `ReferenceError`, `SyntaxError`, `RangeError`,
|
||
"Cannot read propert(y|ies) of (undefined|null)", "is not a
|
||
function", "is not defined". Greift im globalen
|
||
`res.json()`-Wrapper.
|
||
- **POST /contracts substring-Crash**: defensiv – `type` fehlt →
|
||
400 mit klarer Meldung; `generateContractNumber()` fängt auch
|
||
leere/null type ab (Fallback "CON").
|
||
- **Seed-Admin-Passwort "admin" verletzte Policy**: jetzt
|
||
16-Zeichen-Zufallspasswort beim Seed (mit allen 4 Klassen
|
||
garantiert) oder via `SEED_ADMIN_PASSWORD`-ENV überschreibbar;
|
||
BCRYPT-Cost auf 12 (war 10); Passwort wird **einmalig** beim
|
||
Seed in stdout ausgegeben mit Warnung.
|
||
- **AppSettings-Whitelist ergänzt**: `companyName`,
|
||
`defaultEmailDomain` (kommen aus seed.ts, waren in der ersten
|
||
Whitelist vergessen).
|
||
- **Live-verifiziert**: POST /contracts {} → klare 400 statt
|
||
JS-Crash; PUT /users/6 {password:...} ignoriert (Login mit
|
||
altem PW geht weiter); POST /users/6/password mit kurz → 400;
|
||
Cleanup-Script: 1 Customer bereinigt, 2 unbekannte AppSettings
|
||
entfernt (hackerSetting, debugMode), Re-Lauf → 0 Änderungen.
|
||
|
||
- [x] **🚨 Pentest Runde 11 – Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip**
|
||
- **C2 KRITISCH – Factory Reset ohne Bestätigung**:
|
||
Eingeloggter Admin konnte mit leerem oder beliebigem Body
|
||
(`{confirm:true}`, `{confirm:false}`, `{}`) die komplette DB
|
||
plätten (3× in einer Session passiert). Fix: server-side wird
|
||
jetzt `confirm: "FACTORY-RESET-BESTAETIGT"` als String erzwungen,
|
||
sonst HTTP 400. Frontend-API schickt den exakten String mit.
|
||
- **M1 – Settings Mass Assignment**:
|
||
`PUT /api/settings` und `PUT /api/settings/:key` nahmen JEDEN
|
||
Key-Value-Pair an (`superAdminEmail`, `debugMode`,
|
||
`allowedOrigins` etc. landeten direkt in der DB). Fix:
|
||
Whitelist `ALLOWED_SETTING_KEYS` in `appSetting.service.ts`,
|
||
Helper `isAllowedSettingKey()`. Unbekannte Keys → HTTP 400 mit
|
||
expliziter Aufzählung der ungültigen Keys.
|
||
- **M3 – Prisma-Error-Leak in jeder Response**:
|
||
Statt 30+ Controller einzeln zu fixen: globaler `res.json()`-
|
||
Wrapper unter `/api`, der `error`/`details`-Strings durch einen
|
||
Pattern-Filter schickt. Bekannte ORM-/Stack-Trace-Muster
|
||
(`Invalid \`prisma.`, `PrismaClient`, Stack-Frames) werden zu
|
||
`"Operation fehlgeschlagen"` ersetzt. Original-Text bleibt im
|
||
Server-Log via `[orm-leak-guard]`.
|
||
- **M2 – Stored XSS in Customer/User-Strings**:
|
||
`<script>alert(1)</script>` und ähnliche Payloads landeten
|
||
ungefiltert in der DB. Fix: neuer `stripHtml()`-Helper, von
|
||
`pickCustomerUpdate/Create` und `pickUserUpdate/Create` auf
|
||
allen String-Werten angewandt (Defense-in-Depth – React
|
||
auto-escaped schon, aber PDF-Generator/E-Mail-Templates
|
||
könnten exec-Vektoren sein).
|
||
- **Live-verifiziert (alle vier)**:
|
||
* `/factory-reset` mit `{}`, `{confirm:true}`, `{confirm:false}`
|
||
→ HTTP 400, DB unangetastet
|
||
* `PUT /settings {superAdminEmail,debugMode,allowedOrigins}` →
|
||
400 + Keys aufgezählt; gültige Keys → 200
|
||
* `PUT /users/99999` → `"Operation fehlgeschlagen"` statt
|
||
Prisma-Stack; Server-Log behält Original
|
||
* `PUT /customers/3 {companyName:"<script>...</script>EvilCorp"}`
|
||
→ gespeichert als `"EvilCorp"`; `<img onerror>` weg
|
||
|
||
- [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)
|