Das Gegenbuch auf Prod meldete stuendlich exit=2, weil die CRM-Pruefung
wegen 6 struktureller Luecken valid:false lieferte (IDs 33, 44, 45, 922,
1434, 2583).
Diagnose: harmlos. Jede Luecke liegt innerhalb eines Schwungs von
Eintraegen mit identischer Sekunde, betrifft nur /login und /refresh,
kein Eintrag fehlt, tamperedEntries ist leer. Das ist die Signatur der
Race-Condition, die am 19.08. mit AuditChainLock geschlossen wurde
(R166-01). Die Zeilen datieren ihren eigenen Code: Eintrag 2583 stammt
vom 21.08., ist aber noch hashVersion=1.
Das eigentliche Problem war nicht die Luecke, sondern der Dauer-Alarm.
Diese Luecken sind nicht heilbar - die Verkettung ist gebrochen, die
Inhalte sind unversehrt. Ohne Aenderung haette das Gegenbuch fuer immer
Alarm gemeldet, und ein Signal, das immer schreit, warnt nicht mehr.
Dieselbe Klasse wie R162, R174, R179, R182, R183-02.
Loesung: Beglaubigung statt Unterdrueckung. Eine Luecke zaehlt nicht mehr
als offener Befund, wenn sie im versiegelten Bereich liegt UND im
Vorbefund des Siegel-Markers steht - also im Zustand, den der Betreiber
beim Siegeln ausdruecklich festgeschrieben hat. Der Vorbefund liegt in
changesBefore und ist ab Version 3 mitgehasht; ohne AUDIT_HMAC_KEY laesst
sich die Liste nicht nachtraeglich erweitern (gleiche Absicherung wie
beim Loeschungs-Manifest, R171-01). Beglaubigt heisst nicht verschwunden:
die Luecken bleiben in chainGaps, stehen zusaetzlich in attestedGaps und
werden im Bericht ausdruecklich benannt.
Nebenbefund derselben Klasse mitbehoben: backlogSealStatus meldete
"nicht_noetig" ("Es gibt keine unsignierten Alteintraege"), wenn
v3FromId === null - das bedeutet aber das Gegenteil, naemlich dass
ueberhaupt nichts signiert ist, etwa weil AUDIT_HMAC_KEY fehlt. Ein
vollstaendig unsigniertes Log bekam damit Entwarnung fuer genau den
Zustand mit der geringsten Beweiskraft. Die Bedingung haengt jetzt an der
Zahl der unsignierten Zeilen; R183-03 bleibt behoben (Regressionstest).
Getestet ueber HTTP gegen eine eigene Wegwerf-Datenbank, nicht am Service
vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) ->
valid:false; nach seal-backlog -> valid:true, attestedGaps:[5]. Drei
Gegenproben: neue Luecke nach dem Siegeln -> valid:false; gesiegelte
Altzeile veraendert -> Siegel gebrochen, nichts mehr beglaubigt;
Beglaubigungsliste im Marker gefaelscht (DB-Schreibrecht, kein
Schluessel) -> Marker ungueltig, Status entfernt, valid:false.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4114 lines
250 KiB
Markdown
4114 lines
250 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] **🧾 Beglaubigte Alt-Lücken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26)
|
||
- **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die
|
||
CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken**
|
||
(IDs 33, 44, 45, 922, 1434, 2583).
|
||
- **Diagnose: harmlos.** Jede Lücke liegt innerhalb eines Schwungs von
|
||
Einträgen mit **identischer Sekunde**, betrifft nur `/login` und
|
||
`/refresh`, kein Eintrag fehlt (kein 404), `tamperedEntries` leer. Das ist
|
||
die Signatur der Race-Condition, die am 19.08. mit `AuditChainLock`
|
||
geschlossen wurde (R166-01). Bestätigt durch die Zeilen selbst: Eintrag
|
||
2583 stammt vom 21.08., ist aber noch `hashVersion=1` – auf Prod lief zu
|
||
dem Zeitpunkt also der alte Stand.
|
||
- **Das eigentliche Problem war nicht die Lücke, sondern der Dauer-Alarm.**
|
||
Diese Lücken sind nicht heilbar: die Verkettung ist gebrochen, die Inhalte
|
||
sind unversehrt. Ohne Änderung hätte das Gegenbuch für immer Alarm gemeldet
|
||
– und ein Signal, das immer schreit, warnt nicht mehr. Dieselbe Klasse wie
|
||
R162, R174, R179, R182, R183-02.
|
||
- **Lösung: Beglaubigung statt Unterdrückung.** Eine Lücke zählt nicht mehr
|
||
als offener Befund, wenn beides gilt: sie liegt im **versiegelten Bereich**
|
||
UND steht im **Vorbefund des Siegel-Markers**, also im Zustand, den der
|
||
Betreiber beim Siegeln ausdrücklich festgeschrieben hat. Der Vorbefund
|
||
liegt in `changesBefore` und ist ab Version 3 mitgehasht – die Liste lässt
|
||
sich ohne `AUDIT_HMAC_KEY` nicht nachträglich erweitern (gleiche
|
||
Absicherung wie beim Löschungs-Manifest, R171-01).
|
||
- Beglaubigt heißt **nicht verschwunden**: die Lücken bleiben in `chainGaps`,
|
||
stehen zusätzlich in neuem Feld `attestedGaps` und werden im Bericht
|
||
ausdrücklich benannt.
|
||
- **Nebenbefund derselben Klasse mitbehoben:** `backlogSealStatus` meldete
|
||
`nicht_noetig` ("Es gibt keine unsignierten Alteinträge"), wenn
|
||
`v3FromId === null` – das bedeutet aber das **Gegenteil**: dass überhaupt
|
||
nichts signiert ist, etwa weil `AUDIT_HMAC_KEY` fehlt. Ein vollständig
|
||
unsigniertes Log bekam damit Entwarnung für genau den Zustand mit der
|
||
geringsten Beweiskraft. Die Bedingung hängt jetzt an der Zahl der
|
||
unsignierten Zeilen. R183-03 (unauflösbare Warnung bei frisch signiertem
|
||
Log) bleibt behoben – per Regressionstest geprüft.
|
||
- **Getestet über HTTP gegen eine eigene Wegwerf-Datenbank**, nicht am
|
||
Service vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) →
|
||
`valid:false`; nach `seal-backlog` → `valid:true`, `attestedGaps:[5]`.
|
||
Drei Gegenproben: **neue Lücke** nach dem Siegeln → `valid:false`;
|
||
**gesiegelte Altzeile verändert** → Siegel `gebrochen`, nichts mehr
|
||
beglaubigt; **Beglaubigungsliste im Marker gefälscht** (DB-Schreibrecht,
|
||
kein Schlüssel) → Marker ungültig, Status `entfernt`, `valid:false`.
|
||
- Doku: Abschnitt „Wenn der erste Lauf `exit=2` meldet" in
|
||
`tools/audit-notary/README.md` – inklusive der Warnung, **vor** dem
|
||
Siegeln zu prüfen, was man da festschreibt.
|
||
- Dateien: `backend/src/services/audit.service.ts`,
|
||
`backend/src/controllers/auditLog.controller.ts`,
|
||
`frontend/src/services/api.ts`, `tools/audit-notary/README.md`
|
||
|
||
- [x] **🔓 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (Pentest R184-01/-02)** (2026-08-24)
|
||
- **R184-01** – Setzen war gegatet, **Entfernen nicht**. Und das Entfernen ist
|
||
der gefaehrlichere Weg: Der Heartbeat-Wachhund fragt `isServiceAccount:
|
||
true` ab, haengt also am **Live-Kennzeichen**. Seine Hypothese war damit
|
||
richtig – Un-Flaggen kappt die Wache und macht genau das moeglich, wogegen
|
||
der Tripwire gebaut wurde: stilllegen und auf Stille setzen.
|
||
Fix: Gate in beide Richtungen, mit unterschiedlichem Wortlaut (beim
|
||
Entfernen: „faellt aus der Ueberwachung heraus“).
|
||
- **Wichtiger noch:** Die Aenderung geht jetzt zusaetzlich in den
|
||
**Alarmkanal** (`PERMISSION_CHANGED`/CRITICAL), nicht nur ins Audit-Log.
|
||
Eine CRITICAL-Zeile muss jemand LESEN – das war die R183-02-Klasse. Jetzt
|
||
reagiert die Ueberwachung automatisch.
|
||
- **R184-02** – Das Kennzeichen hing an `users:update`, einer
|
||
Anwendungs-Berechtigung, obwohl es ein Audit-Governance-Eingriff ist –
|
||
Geschwister von retention-shorten, seal-backlog, rehash und cleanup, die
|
||
alle `audit:admin` verlangen. Heute deckungsgleich, aber jede kuenftige
|
||
Rolle mit „Benutzer bearbeiten“ haette still „Login-Alarme herunterstufen“
|
||
geerbt. Fix: zusaetzliche Pruefung auf `audit:admin` im Controller.
|
||
- Verifiziert: Setzen mit `audit:admin` 200 · Entfernen ohne Bestaetigung
|
||
**400** (vorher 200) · mit Bestaetigung 200 · ohne `audit:admin` **403** ·
|
||
zwei CRITICAL-Meldungen im Alarmkanal mit sprechendem Text. Frontend sendet
|
||
die Bestaetigung in beide Richtungen. `npm run build` und Backend-`tsc`
|
||
gruen.
|
||
- Seine Non-Findings uebernommen: Selbst-Block nicht ueber Methode, Pfad oder
|
||
ID-Aliasing umgehbar; Create streift das Feld ab; Confirm-Gate strikt.
|
||
|
||
- [x] **🔒 Dienstkonto-Kennzeichen gegatet, laut protokolliert, kein Selbstbedienen (Pentest R184)** (2026-08-24)
|
||
- Der Pentester hat sofort erkannt, was das Scharfschalten des Feldes
|
||
bedeutet: Ein Attribut, das die **Alarmstufe senkt**, war ueber den
|
||
normalen Benutzer-Update-Pfad setzbar – dieselbe Klasse wie R183-01,
|
||
nur neu gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen
|
||
auffaelligen Anmeldungen als Routine erscheinen. Das ist die Waesche.
|
||
- Ist-Zustand vor dem Fix, alle drei seiner Sorgen bestaetigt: kein Gate,
|
||
Protokollierung nur als MEDIUM (Standardstufe fuer `User`), und jeder mit
|
||
`users:update` konnte es auf jedes Konto setzen – auch auf das eigene.
|
||
- Fix, analog zur Retention-Absenkung:
|
||
* **Bestaetigung** `{"confirm":"SERVICE_ACCOUNT"}` beim Aktivieren, mit
|
||
Klartext, was das bedeutet.
|
||
* **CRITICAL** statt MEDIUM, mit eigenem Label („Dienstkonto-Kennzeichen
|
||
GESETZT fuer … – Anmeldungen werden kuenftig als Routine gefuehrt“) sowie
|
||
Wer/Vorher/Nachher.
|
||
* **Kein Selbstbedienen**: am eigenen Konto ist das Kennzeichen weder
|
||
setzbar noch entfernbar (403 mit Begruendung) – muss ein anderer
|
||
Administrator vornehmen.
|
||
- Frontend sendet die Bestaetigung mit; der Haken im Formular IST die
|
||
Bestaetigung, der Betreiber merkt nichts davon.
|
||
- Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200,
|
||
am eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label.
|
||
`npm run build` (inkl. `tsc`) und Backend-`tsc` gruen.
|
||
|
||
- [x] **🖱️ Dienstkonto-Kennzeichen in der Benutzerverwaltung** (2026-08-24)
|
||
- Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war
|
||
aber **nirgends setzbar** – weder im Formular noch ueber die API. Der
|
||
Betreiber haette es nicht aktivieren koennen, damit waere die
|
||
R182-Einstufung wirkungslos geblieben.
|
||
- Jetzt: Ankreuzfeld „Dienstkonto“ im Benutzerformular unter den
|
||
zusaetzlichen Berechtigungen, mit Erklaerung im Klartext (planmaessige
|
||
Anmeldungen als Routine, dafuer Meldung bei AUSBLEIBEN). Feld in der
|
||
Whitelist (`USER_UPDATABLE_FIELDS`, damit auch beim Anlegen), im
|
||
Service-Typ und in allen drei `select`-Bloecken, damit es beim Bearbeiten
|
||
vorbelegt wird.
|
||
- Verifiziert: Whitelist laesst das Feld durch und blockt Fremdfelder
|
||
weiterhin; Wert ueber Prisma lesbar; `tsc` und `vite build` gruen.
|
||
|
||
- [x] **🚨 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)** (2026-08-24)
|
||
- **R183-01 (HIGH)** – Die geladene Waffe war ungegatet, der Abzug gegatet:
|
||
`PUT /retention-policies/{id}` nahm `retentionDays: 0` **ohne
|
||
Bestaetigung** an und protokollierte es als MEDIUM – waehrend cleanup,
|
||
seal und rehash alle ein Confirm-Gate haben und die FOLGE CRITICAL ist.
|
||
Die ausloesende Tat war leiser als ihre Wirkung.
|
||
Fix: Absenken verlangt `{"confirm":"SHORTEN"}`, wird als **CRITICAL** mit
|
||
Vorher/Nachher protokolliert („Aufbewahrung VERKUERZT … 730 → 0 Tage“), und
|
||
fuer `Authentication`/`AuditLog` gilt eine **Untergrenze von 30 Tagen**.
|
||
`logChange` nimmt dafuer jetzt Sensitivitaet und Vorzustand entgegen.
|
||
- **R183-02 (HIGH)** – Nach dem Cleanup meldete verify
|
||
„**Keine Manipulation.** 656 strukturelle Luecken (alle durch protokollierte
|
||
Loeschungen erklaert)“ – bei `valid: false` und 3010 endgueltig geloeschten
|
||
Anmeldeprotokollen. Der Befund stand nur im Feld, die Prosa beruhigte.
|
||
Dieselbe Alarm-Muedigkeit wie R162/R182, diesmal im Verifizierer selbst.
|
||
Fix: Bei Luecken heisst es jetzt „Die Kette ist nicht mehr lueckenlos …
|
||
geloeschte Eintraege lassen sich naturgemaess nicht mehr pruefen“.
|
||
Zusaetzlich wertet das **Gegenbuch `valid:false` hart** – es ruft
|
||
`/verify` mit und schlaegt Alarm, egal wie der Text klingt.
|
||
- **R183-03 (MEDIUM)** – verify warnte dauerhaft „Altbestand nicht
|
||
versiegelt“, waehrend seal-backlog zu Recht ablehnte („kein Altbestand“).
|
||
Eine Warnung, die niemand aufloesen kann, lernt man zu ignorieren.
|
||
Fix: eigener Zustand `nicht_noetig` mit Klartext; die echte Warnung nennt
|
||
jetzt den Befehl zum Beheben.
|
||
- **Heartbeat-Wachhund gebaut** (seine wichtigere Haelfte): Bleibt ein
|
||
Dienstkonto laenger still als `SERVICE_ACCOUNT_MAX_SILENCE_MINUTES`
|
||
(Standard 180), gibt es `SUSPICIOUS/CRITICAL`. Ohne je gesehene Anmeldung
|
||
wird geschwiegen statt geraten; pro Ausfall wird genau einmal gemeldet.
|
||
Verifiziert: keine Grundlinie → still; 500 Minuten Stille → Meldung;
|
||
Wiederholung → keine Dublette.
|
||
- Seine Non-Findings bestaetigt uebernommen: Confirm-Gates gegen neun
|
||
Umgehungsvarianten dicht, rehash-Protokoll ehrlich, Tombstone-Mechanik und
|
||
Anker-Backstop intakt.
|
||
|
||
- [x] **🔧 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken** (2026-08-22)
|
||
- Fehlerbild aus dem echten Betrieb: `mkdir: cannot create directory
|
||
'/gegenbuch/schluessel': Permission denied`, Container in der
|
||
Neustart-Schleife. Ursache: Das Datenverzeichnis kommt als Bind-Mount vom
|
||
Host; der Betreiber hatte das Projekt als **root** geklont, der Container
|
||
lief aber direkt als UID 1000 und durfte dort nichts anlegen.
|
||
Mein `.gitkeep`-Ansatz hatte stillschweigend angenommen, dass als normaler
|
||
Benutzer geklont wird – auf einem Server ist root der Normalfall.
|
||
- Fix: Der Container startet als root, setzt `/gegenbuch` per `chown` auf den
|
||
Arbeitsbenutzer (`PUID`/`PGID`, Standard 1000) und startet sich per
|
||
`setpriv` als dieser neu. Die eigentliche Arbeit laeuft damit weiterhin
|
||
unprivilegiert. Schlaegt das `chown` fehl (z. B. rootless Docker), gibt es
|
||
einen Hinweis mit dem passenden Host-Befehl statt eines stummen Abbruchs.
|
||
- Verifiziert mit exakt der Ausgangslage: Datenverzeichnis auf `root:root`
|
||
gesetzt, Container gestartet → laeuft durch, Schluessel und Buch werden
|
||
angelegt, erzeugte Dateien gehoeren 1000:1000, der private Schluessel liegt
|
||
mit `0600`.
|
||
|
||
- [x] **🔑 Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten** (2026-08-22)
|
||
- **Blocker gefunden und behoben:** Ich hatte ein dauerhaftes API-Token
|
||
vorausgesetzt – das gibt es in OpenCRM gar nicht. Zugangstoken leben
|
||
**15 Minuten**; der Gegenbuch-Container waere nach dem ersten Durchlauf
|
||
gestorben. Aufgefallen erst, als der Betreiber fragte, woher er den Token
|
||
nimmt.
|
||
Loesung: Das Gegenbuch meldet sich bei **jedem Lauf selbst an** – mit einem
|
||
eigenen Benutzerkonto, dessen Rolle ausschliesslich `audit:read` traegt.
|
||
Damit kann es nur Pruefwerte lesen: keine Kundendaten, keine Aenderungen.
|
||
`CRM_TOKEN` bleibt fuer Tests moeglich, ist aber nicht mehr der Normalweg.
|
||
- Fehlerfaelle sauber gemeldet: falsches Passwort → Klartext statt HTTP-Code,
|
||
Anmelde-Bremse (429) benannt, CRM nicht erreichbar unterschieden.
|
||
- **`.env.example` nennt jetzt die gueltigen Werte** – bisher liess sich nur
|
||
raten, ob es `prod` oder `production` heisst. Fuer jeden Schalter steht
|
||
dabei, was erlaubt ist, inkl. des Falls „erst nur Staging testen, Prod
|
||
spaeter dazunehmen“ (`COMPOSE_PROFILES=staging` → `prod,staging`).
|
||
- Beide READMEs um „Zugang einrichten“ ergaenzt: Rolle mit nur `audit:read`,
|
||
Benutzer damit, Zugangsdaten in die `.env`. Mit dem Hinweis, dass jede
|
||
Anmeldung im Audit-Log erscheint – gewollt, denn so sieht man auch, wenn
|
||
das Gegenbuch aufhoert zu arbeiten.
|
||
- Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung
|
||
verlangt: Anmeldung + Lauf erfolgreich, falsches Passwort → verstaendliche
|
||
Meldung, exit 1.
|
||
|
||
- [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22)
|
||
- Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine**
|
||
fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch
|
||
liegt lokal auf dieser Maschine. Die Trennung, auf die es ankommt, ist
|
||
damit gegeben – wer OpenCRM uebernimmt, kommt nicht ans Buch.
|
||
- Richtung bewusst so herum: Das Gegenbuch **holt** ueber HTTPS mit einem
|
||
Token, das nur `audit:read` kann. OpenCRM kennt weder Adresse noch
|
||
Schluessel des Gegenbuchs. Kein SSH-Zugang zum CRM noetig – das Token kann
|
||
ausschliesslich Pruefwerte lesen.
|
||
- Unterverzeichnis `tools/audit-notary/`: `Dockerfile`, `entrypoint.sh`,
|
||
`docker-compose.yml`, `.env.example`. Zwei Dienste (`prod`, `staging`) mit
|
||
getrennten Verzeichnissen und Schluesseln, gesteuert ueber
|
||
`COMPOSE_PROFILES` – dasselbe Muster wie beim Caddy-Profil im Hauptstack.
|
||
Der Signaturschluessel wird beim ersten Start auf der Gegenbuch-Maschine
|
||
erzeugt und verlaesst sie nie.
|
||
- **Lokaler Betrieb ist jetzt ein vollwertiger Modus**, kein Testschalter
|
||
mehr. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist
|
||
(„jemand veraendert Daten im CRM“) und was nicht („jemand uebernimmt diesen
|
||
Rechner hier“) – statt der frueheren pauschalen Formulierung „kein
|
||
Manipulationsschutz“, die im Einsatz auf eigener Maschine schlicht falsch
|
||
war.
|
||
- Jeder Durchlauf schreibt `status.txt` fuer die Ueberwachung; alles ausser
|
||
`exit=0` gehoert angesehen.
|
||
- Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne
|
||
Bestaetigung → Code 4; mit Bestaetigung → Buch angelegt, Normalbetrieb
|
||
exit 0; **Eintrag nachtraeglich veraendert → Alarm exit 2**; **Eintraege
|
||
geloescht (maxId geschrumpft) → Alarm exit 2**; Siegel verschwunden →
|
||
Alarm. Testartefakte und Image geloescht.
|
||
- **Daten im Projektverzeichnis, keine Docker-Volumes** (Projektkonvention):
|
||
Bind-Mount auf `tools/audit-notary/data/<instanz>/`. Der Inhalt ist per
|
||
`.gitignore` ausgenommen – dort liegt der Signaturschluessel –, die
|
||
Verzeichnisse selbst bestehen ueber `.gitkeep`. Ohne das haette Docker sie
|
||
beim ersten Start als `root` angelegt und der Container (UID 1000) haette
|
||
nicht schreiben koennen.
|
||
- Behoben beim Bauen: `useradd -u 1000 || true` verschluckte, dass UID 1000
|
||
im Node-Image bereits vergeben ist – der Container startete gar nicht.
|
||
Jetzt laeuft er als der vorhandene Benutzer `node`.
|
||
|
||
- [x] **🧮 Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (Pentest R181)** (2026-08-18)
|
||
- **R181-01 (LOW→MED)** – Der Pruefmodus sagte zu, nichts zu veraendern und
|
||
kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()`
|
||
lief in JEDEM Modus. Ein „read-only“ Audit mutierte damit still das
|
||
geteilte Substrat. Fix: Nachziehen nur im Schreiblauf.
|
||
- **R181-02 (MEDIUM) – ein Widerspruch zwischen meinen EIGENEN Fixes.**
|
||
R180-01 erhebt die Serversperre auf `refs/notary/*` zur tragenden Pflicht;
|
||
R180-02 verlangt genau dort Schreibrecht zur Selbstheilung. Ergebnis:
|
||
Sobald je ein Anker fehlte, bekam jeder read-only pruefende Auditor
|
||
dauerhaft einen Fehler auf einer **voellig gueltigen** Kette – und konnte
|
||
ihn nicht beheben.
|
||
Fix: Reparieren ist Sache des Notar-Laufs; im Pruefmodus wird der fehlende
|
||
Anker gemeldet, nicht repariert.
|
||
- **Eigener Rueckgabecode 5** (seine Frage b): „Anker unvollstaendig“ ist
|
||
NICHT „nicht feststellbar“. Die Kette ist geprueft und gueltig, nur das
|
||
Substrat-Gedaechtnis ist unvollstaendig – ein benannter, reparierbarer
|
||
Defekt mit klarer Handlungsanweisung. Ihn in Code 3 zu werfen hiesse, eine
|
||
Anweisung als Ungewissheit zu melden; dieselbe Trennung wie bei
|
||
Genesis/Adoption.
|
||
- **Seine Frage (a) empirisch beantwortet:** Die Notar-Identitaet laesst sich
|
||
eng auf das ANLEGEN von `refs/notary/*` beschraenken, ohne ihr Loeschen
|
||
oder Ueberschreiben zu geben – serverseitig unterscheidbar an der Null-OID
|
||
beim Anlegen. Mit `pre-receive`-Hook verifiziert: Backfill greift (exit 0),
|
||
Loeschen und Force-Overwrite werden weiterhin abgewiesen. Der Hook steht
|
||
als Beispiel in der README.
|
||
- Verifiziert: read-only `--check` mit fehlendem Anker → **Code 5, nichts
|
||
gepusht** (Anker-Anzahl vorher/nachher identisch); Notar-Schreiblauf unter
|
||
derselben ACL → „Fehlende Anker nachgetragen: 1“, exit 0.
|
||
- Seine Non-Findings uebernommen: Die Backfill-Zuordnung ist gegen das
|
||
PIN-Modell robust – jeder Commit mit echter Ledger-Aenderung ist non-merge
|
||
und muss keyA-signiert sein, sig-exempte Merges tragen keinen Inhalt in die
|
||
Zuordnungsliste.
|
||
|
||
- [x] **🛑 Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (Pentest R180-01/-02)** (2026-08-18)
|
||
- **R180-01 (MEDIUM)** – Genau die Unsicherheit, die ich selbst benannt
|
||
hatte, bestaetigt: Der Code nahm den **hoechsten noch vorhandenen**
|
||
`refs/notary/seq-*` als „hoechsten je“. Loescht ein Angreifer nur die
|
||
OBEREN Anker und laesst einen niedrigeren stehen, senkt er den
|
||
Vergleichswert selbst – ein frischer Auditoren-Klon meldete `OK`, exit 0
|
||
auf gewaschenem Stand.
|
||
Perverser Gradient: Wer ALLE Refs loeschte, flog auf (Code 4); wer weniger
|
||
loeschte, kam durch – weil `ankerBelegt` sowohl die Gewissheit begruendete
|
||
als auch den Code-4-Diskriminator kurzschloss.
|
||
Fix: **Anker begruenden keine Gewissheit mehr.** Sie koennen ein
|
||
Zurueckspulen *widerlegen* (hoher Anker nicht mehr im Kopf → Alarm), aber
|
||
nie Unversehrtheit *belegen*. Der Code-4-Diskriminator haengt nicht mehr an
|
||
ihnen und greift nur noch im Schreiblauf (ein Auditor hatte nie ein
|
||
Gedaechtnis, ihm Code 4 vorzuwerfen waere Laerm).
|
||
- **R180-02 (LOW→MED)** – Der Anker-Push-Fehlschlag war nur eine Warnung mit
|
||
exit 0, ausgerechnet bei der tragenden Eigenschaft: `main` stand auf dem
|
||
neuen Checkpoint, der Anker fehlte, niemand holte es nach. Der
|
||
**Normalbetrieb** senkte damit den Hoechststand still um eins und riss das
|
||
R180-01-Fenster ohne Angriff auf.
|
||
Fix: Anker-Push-Fehlschlag → **exit 3**; und jeder Lauf **zieht fehlende
|
||
Anker nach**, bevor er irgendetwas als OK meldet (Zuordnung ueber die
|
||
n-te Aenderung am Gegenbuch).
|
||
- Verifiziert: Teil-Loeschung (main auf seq-2, Anker 3–5 weg, DB gekuerzt),
|
||
frischer Auditoren-Klon → **exit 3 mit Vorbehalt statt exit 0** (das falsche
|
||
Gruen ist weg; erkannt wird es in-repo weiterhin nicht, das geht ohne
|
||
Ref-Schutz nicht); Anker-Push per Hook abgelehnt → **exit 3** statt
|
||
Hinweis; Folgelauf → „Fehlende Anker nachgetragen: 1“, Anker wieder
|
||
vollstaendig.
|
||
- **README:** Der Schutz von `refs/notary/*` gegen Loeschen und Ueberschreiben
|
||
ist von der Fussnote zur **tragenden Voraussetzung** erhoben – mit der
|
||
Begruendung, warum Anker nichts belegen koennen, und dem ausdruecklichen
|
||
Hinweis, dass ein `--check` aus frischem Klon eine Anker-Loeschung nicht
|
||
erkennt.
|
||
|
||
- [x] **🧠 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (Pentest R180)** (2026-08-18)
|
||
- Sein Durchrechnen war zwingend: „erste Beobachtung → exit 3“ galt **genau
|
||
einen Lauf**. Danach baselinete der Waechter auf den AKTUELLEN Kopf – nach
|
||
einem Rewind also auf den gewaschenen Stand – und meldete dauerhaft gruen.
|
||
Der Angreifer musste nur **ein einziges exit 3** ueberstehen, ausgerechnet
|
||
den Code, den der Betreiber bei Remote-Ausfaellen ohnehin staendig sieht.
|
||
Damit war der Waechter, der Rewind OHNE Notar-Host-Integritaet fangen
|
||
sollte, wieder an genau diese Integritaet gekoppelt.
|
||
- **Eigentlicher Fix (sein Vorschlag): Gedaechtnis ins Append-only-Substrat.**
|
||
Jeder verankerte Checkpoint bekommt einen eigenen Ref
|
||
`refs/notary/seq-N`. Der ueberlebt einen Force-Push auf den Zweig, weil er
|
||
ein anderer Ref ist – die hoechste je existierende Nummer ist damit aus dem
|
||
Server rekonstruierbar. Geprueft wird: ist der hoechste verankerte
|
||
Checkpoint noch im aktuellen Kopf enthalten, und ist die Reihe mindestens
|
||
so lang wie verankert.
|
||
- **Laute Verlustbehandlung mit seinem Diskriminator** („traegt der Remote
|
||
schon Checkpoints?“): keine Historie → echte Genesis, einmalig
|
||
`NOTARY_GENESIS_ACK=true`; Historie vorhanden, aber kein Gedaechtnis →
|
||
**Anomalie**, eigener **Code 4**, **keine stille Adoption**, erst nach
|
||
`NOTARY_ADOPT_ACK=true`. Eine Baseline zu setzen heisst „ich verbuerge
|
||
mich, DAS ist der wahre Kopf“ – dieser Akt wird nicht automatisch
|
||
wiederholt.
|
||
- Verifiziert: Genesis ohne Bestaetigung → **Code 4**; mit Bestaetigung →
|
||
Kette aufgebaut, `refs/notary/seq-1..5` auf dem Server; Rewind auf
|
||
Checkpoint 2 → Alarm **exit 2**, und zwar **auch nach Loeschen des lokalen
|
||
Speichers** und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft
|
||
gruen); zusaetzlich die Anker-Refs geloescht → **Code 4** statt stiller
|
||
Uebernahme, erst nach ausdruecklicher Bestaetigung weiter (dann exit 3 mit
|
||
Vorbehalt, nicht gruen). Labor und Port geraeumt.
|
||
- **Ehrlich dokumentiert:** Die Anker-Refs muessen serverseitig ebenfalls vor
|
||
Loeschen/Ueberschreiben geschuetzt sein – sonst verschiebt sich das Problem
|
||
nur eine Ebene weiter. Server-API-Abfrage der Protection bleibt bewusst
|
||
weg (er hat sie selbst nur als Plausibilisierung eingeordnet).
|
||
|
||
- [x] **👀 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (Pentest R179 a/b)** (2026-08-18)
|
||
- **(a) Probe-Push verworfen – seine Analyse war zwingend.** Ein Testpush
|
||
haette (1) nur den geprobten Ref beurteilt, nicht `main`; (2) die EIGENE
|
||
Push-Identitaet gemessen statt die des Angreifers – Bypass-Rechte fuer
|
||
Admins gehen genau dann auseinander, wenn es zaehlt; (3) nur einen
|
||
Zeitpunkt abgedeckt und damit die Praesenz-erzeugt-Zuversicht-Falle aus
|
||
R179-01 neu gebaut. Und: einen zerstoerungsfreien Force-Push gibt es nicht –
|
||
die bestaetigende Beobachtung waere derselbe Vorgang wie der Schaden.
|
||
- Stattdessen **Fast-Forward-Waechter**: Bei jedem Lauf wird der beobachtete
|
||
Remote-Kopf **ausserhalb des Klons** festgehalten
|
||
(`~/.opencrm-notary/beobachtungen.jsonl`, per `NOTARY_STATE_FILE`
|
||
aenderbar). Beim naechsten Lauf muss der neue Kopf ein Nachfahre des alten
|
||
sein – sonst Alarm. Das erkennt das **Ereignis** statt die Regel
|
||
abzufragen und wirkt unabhaengig davon, ob serverseitiger Schutz existiert.
|
||
Ein belegter Fast-Forward gilt als Nachweis und blendet den Rewind-Vorbehalt
|
||
aus (im Gegensatz zur blossen Praesenz eines MIN_SEQ-Werts).
|
||
- **(b) Code 3 als Tor VOR dem Anhaengen, nicht als Status danach.** Sein
|
||
Argument: Der Schreiblauf signiert mit dem neuen Checkpoint zugleich ueber
|
||
den Bestand darunter – ist die Basis ungeklaert, waere das Anhaengen selbst
|
||
das Waschmittel. Jetzt: Grundlage nicht feststellbar → **nichts anhaengen,
|
||
exit 3**. Erster Lauf ueberhaupt → Basislinie setzen, aber ehrlich als
|
||
solche melden (exit 3, kein gruenes Licht). Anhaengen geklappt, Push
|
||
gescheitert → **exit 3 mit „erstellt, aber NICHT verankert“** statt 0 oder
|
||
1. Nur Anhaengen + Push + belegte Verankerung → exit 0.
|
||
- Verifiziert: Basislinie → exit 3; Folgelauf → exit 0 ohne Vorbehalt;
|
||
**Rewind aus einem FRISCHEN Auditoren-Klon ohne MIN_SEQ und ohne
|
||
Zusicherung → Alarm exit 2** (das war bisher stilles Gruen); Push-Ziel
|
||
kaputt → „erstellt, aber NICHT verankert“ exit 3, Folgelauf haelt den
|
||
ungepushten Commit fail-closed an (exit 2). Labor und Port geraeumt.
|
||
|
||
- [x] **🚦 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)** (2026-08-18)
|
||
- **R179-b – der wichtigere Punkt:** Mein Rewind-Vorbehalt stand in der
|
||
Ausgabe, der Exit blieb **0**. Meine eigene README sagt „fuer Cron gilt:
|
||
jeder Code ausser 0 gehoert gemeldet“ – der Zustand „ich bin an dieser
|
||
Stelle blind“ erreichte die Ueberwachung also **nie**. Dieselbe Klasse wie
|
||
R174 (vergrabener Vorbehalt), eine Ebene hoeher.
|
||
Fix: Code **0 nur bei belegter Gewissheit** – entweder ist serverseitiger
|
||
Rewind-Schutz zugesichert oder eine mitgegebene Mindesthoehe wurde
|
||
erfuellt. Sonst **Code 3**, dieselbe Bedeutung wie beim unerreichbaren
|
||
Remote und bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
|
||
- **R179-01 (MEDIUM)** – `NOTARY_MIN_SEQ` ist eine **Untergrenze**, kein
|
||
Ist-Stand. Ein veralteter Wert liess einen Teil-Rewind darueber lautlos
|
||
durch; schlimmer: die blosse **Praesenz** einer Zahl blendete den Vorbehalt
|
||
aus, ein `MIN_SEQ=0` war damit ein Freibrief und ein Tippfehler (`NaN`)
|
||
wurde still verschluckt. Ein veralteter Anker erzeugte so ein
|
||
**selbstbewussteres Ergebnis als gar keiner**.
|
||
Fix: Der Vorbehalt haengt jetzt allein an der ausdruecklichen Zusicherung
|
||
`NOTARY_REWIND_PROTECTED=true`; bei gesetztem MIN_SEQ benennt er zusaetzlich
|
||
die Grenze („deckt nur ab, was UNTERHALB dieser Nummer fehlt“).
|
||
`MIN_SEQ` ≤ 0 oder unparsbar → **exit 1** mit Erklaerung statt stiller
|
||
Annahme.
|
||
- Verifiziert gegen seinen Aufbau (10 Checkpoints, Rewind auf 7, DB passend
|
||
gekuerzt, frischer Auditoren-Klon):
|
||
nicht gesetzt → **exit 3** (vorher 0) · 10 → Alarm 2 · 8 → Alarm 2 ·
|
||
7 (veraltet) → 0, aber **mit Vorbehalt** (vorher ohne) · 0 / `xyz` →
|
||
**exit 1** (vorher stilles 0) · Rewind-Schutz zugesichert → 0 ohne
|
||
Vorbehalt. Labor und Port geraeumt.
|
||
- Seine Positivkontrolle bestaetigt und uebernommen: „in der Mitte gewaschen“
|
||
ist dicht, weil der Kopf-Hash bei `atId=letzter.maxId` alles darunter
|
||
abdeckt – die Reconciliation nur des letzten Checkpoints genuegt.
|
||
|
||
- [x] **🔄 Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)** (2026-08-18)
|
||
- Fund: Mein Signatur-Gate faengt Force-Push mit **fremder oder unsignierter**
|
||
Historie – aber ein **Rewind auf einen aelteren, echt signierten Stand**
|
||
ist signaturseitig einwandfrei. Angreifer spult `origin/main` auf einen
|
||
frueheren Checkpoint zurueck und kuerzt die Datenbank passend: alle
|
||
Signaturen `G`, Pin korrekt, Reihe lueckenlos. Ein Notar-Klon mit lokalem
|
||
Vorlauf merkt es; ein **frischer Auditoren-Klon** meldete `OK`, exit 0 –
|
||
also ausgerechnet im dokumentierten Pruef-Fall.
|
||
- **Ehrliche Einordnung:** Das laesst sich im Skript nicht kryptographisch
|
||
erkennen, die Historie ist ja echt. Also zwei Dinge statt eines
|
||
Scheinfixes:
|
||
1. `NOTARY_MIN_SEQ` – die zuletzt bekannte Nummer als Bezugspunkt. Ist die
|
||
Reihe kuerzer, ist das das Bild eines Rewinds → Alarm. Jeder Lauf nennt
|
||
die Nummer am Ende, damit sie ueberhaupt bekannt sein kann.
|
||
2. Ohne diesen Bezugspunkt sagt die Erfolgsmeldung jetzt ausdruecklich,
|
||
dass ein Zurueckspulen **nicht** erkennbar war – „OK“ soll nicht mehr
|
||
Gewissheit behaupten als vorhanden ist.
|
||
- README: serverseitiger **Rewind-Schutz (non-fast-forward verbieten)** ist
|
||
jetzt als Pflicht formuliert, mit der Begruendung warum Signaturpruefung
|
||
allein dagegen nichts ausrichtet – und mit dem ausdruecklichen Hinweis,
|
||
dass ein frischer Klon den Rewind nicht sieht.
|
||
- Kleinkram aus seinem Bericht: `NOTARY_SIGNER_FINGERPRINT` wird beim
|
||
**Einlesen** getrimmt (ein Zeilenumbruch loeste sonst 4/4 Fehlalarme aus,
|
||
die auf den *korrekten* Fingerabdruck zeigten); die „nie gepusht“-Meldung
|
||
priorisiert jetzt **Untersuchen** statt Pushen und nennt den passenden
|
||
`git log`-Befehl – „pushen“ haette einen falsch signierten Commit dauerhaft
|
||
in die Kette gebracht.
|
||
- Beim Testen selbst gefunden: der allererste git-Aufruf war ungeschuetzt und
|
||
warf bei kaputtem Klon einen Node-Stacktrace – jetzt erklaerende Meldung.
|
||
- Verifiziert: Rewind 3→1 bei passend gekuerzter DB → frischer Klon ohne
|
||
Bezugspunkt `OK` **mit Vorbehalt**, mit `NOTARY_MIN_SEQ=3` → **Alarm
|
||
exit 2**; Pin mit Zeilenumbruch → kein Fehlalarm mehr; regulaerer Lauf
|
||
unveraendert. Testlabor und Port geraeumt.
|
||
- Seine Bestaetigungen: Pin-Ableitung faellt bei Key-Literal/GPG-Key-ID
|
||
korrekt fail-closed aus; der Rollback bei Pin-Mismatch ist vollstaendig,
|
||
und selbst ein Absturz zwischen Commit und Reset wird vom
|
||
„nie gepusht“-Check aufgefangen.
|
||
|
||
- [x] **📌 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (Pentest R177)** (2026-08-18)
|
||
- **R177-01 (MEDIUM)** – Der Pin war optional. Ohne ihn war der
|
||
Vertrauensanker die gesamte `allowed_signers`-**Menge**, nicht der eine
|
||
Notar-Schluessel: Ein zweiter dort gelisteter Schluessel (Kollege,
|
||
Alt-Key, geteilte Datei) konnte das Gegenbuch waschen, force-pushen – und
|
||
`%G?` war `G`. Live gezeigt, exit 0. R176-01 hatte „jeder selbst erzeugte
|
||
Schluessel“ geschlossen, „jeder **erlaubte** Schluessel“ blieb offen.
|
||
Fix: Pin wird aus `user.signingkey` automatisch abgeleitet
|
||
(`ssh-keygen -lf` → `SHA256:…`); laesst er sich nicht bestimmen, wird
|
||
**abgebrochen** statt die ganze Liste zu akzeptieren.
|
||
- **R177-02 (MEDIUM)** – Der Schreib-Lauf prueft den frischen Commit nur auf
|
||
`%G?`, nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem
|
||
Schluessel meldete „beglaubigt“ und **pushte** – und ab da war die Kette
|
||
bei jeder Folgepruefung dauerhaft rot, behebbar nur per Force-Push, den
|
||
die Branch-Protection gerade verhindern soll. Selbst-DoS ohne Signal.
|
||
Fix: Pin-Abgleich am frischen Commit **vor** dem Push, bei Abweichung
|
||
Ruecknahme und Abbruch.
|
||
- Kleinkram aus seinem Bericht: `NOTARY_ALLOW_LOCAL=true` faerbt jede
|
||
Erfolgsmeldung jetzt ein („nur lokal, kein externer Anker“) und pusht
|
||
nicht mehr ins Leere; CRM-Fehler (nicht erreichbar, 401/403, kein JSON)
|
||
liefern eine erklaerende Zeile statt eines Node-Stacktrace.
|
||
- Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff
|
||
(in `allowed_signers`, aber nicht der Notar-Key) → **Alarm exit 2 auch
|
||
ohne gesetzten Pin**; Schreiblauf mit falschem Schluessel → zurueckgerollt,
|
||
**nichts gepusht**, keine Geisterzeile; CRM nicht erreichbar / 401 →
|
||
saubere Meldung, exit 1; Lokalmodus → Erfolgsmeldung eingefaerbt;
|
||
**saubere Historie: vier Laeufe plus Pruefmodus ohne Fehlalarm**.
|
||
Alle Testartefakte geloescht, Ports frei.
|
||
- Seine Antworten uebernommen: (a) `%GF` ist kryptographisch an die Signatur
|
||
gebunden und nicht faelschbar – Non-Finding; (b) Code 3 bleibt getrennt von
|
||
Code 2, „ich weiss es nicht“ darf nicht als „Manipulation“ gemeldet werden;
|
||
(c) „Notar-Host **und** Key kompromittiert“ bleibt bewusst offen – dagegen
|
||
hilft nur Branch-Protection am Remote, im Skript nicht erzwingbar.
|
||
|
||
- [x] **🔑 Gegenbuch: nur wirklich vertrauenswuerdige Signaturen, fail-closed ohne Remote (Pentest R176)** (2026-08-18)
|
||
- **R176-01 (HIGH)** – Mein Signatur-Gate akzeptierte `%G?` = `G` **oder**
|
||
`U`. Bei SSH-Signaturen bedeutet `U` woertlich „gute Signatur, aber kein
|
||
passender Principal“ – also: der Schluessel steht **nicht** in
|
||
`allowed_signers`. Damit passierte **jeder selbst erzeugte Schluessel** das
|
||
Gate, und der einzige In-System-Vertrauensanker war wirkungslos. Der
|
||
Pentester hat es end-to-end gefahren: Gegenbuch mit fremdem Schluessel
|
||
re-signiert und force-gepusht → „OK, Signaturen gültig“, exit 0.
|
||
Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff.
|
||
Fix: **nur `G`** an beiden Stellen. Zusaetzlich optionales Pinnen des
|
||
erwarteten Signierschluessels ueber `NOTARY_SIGNER_FINGERPRINT` (`%GF`).
|
||
- **R176-02 (MEDIUM)** – War der Remote unerreichbar, fiel der Ablauf still
|
||
auf `HEAD` zurueck und die „nie gepusht“-Pruefung wurde uebersprungen –
|
||
ausgerechnet unter der Bedingung, die einen Push-Fehlschlag verursacht.
|
||
`--check` meldete waehrend eines Remote-Ausfalls gruenes Licht auf nicht
|
||
notarisiertem Zustand. Fix: **fail-closed**, Abbruch mit Code 3 statt dem
|
||
lokalen Stand zu glauben; ebenso, wenn gar kein Remote konfiguriert ist
|
||
(bewusster Testlauf nur mit `NOTARY_ALLOW_LOCAL=true`).
|
||
- Nebenbei: git-eigene Fehlermeldungen standen VOR der eigenen Erklaerung –
|
||
stderr wird jetzt abgefangen und gezielt weitergereicht.
|
||
- Verifiziert mit zwei SSH-Schluesseln (keyA vertrauenswuerdig, keyB nicht)
|
||
gegen echten Remote: Angriff mit keyB + Force-Push → **Alarm, exit 2**
|
||
(vorher „OK“); Remote unerreichbar → **exit 3**; kein Remote → exit 3;
|
||
falscher Fingerabdruck-Pin → Alarm; **saubere Historie: drei Laeufe und
|
||
Pruefmodus ohne Fehlalarm**. Alle Testartefakte geloescht, Port frei.
|
||
- Seine Antwort auf Frage (a) uebernommen: der Vergleich Arbeitsdatei ↔
|
||
signierter Baum ist nicht umgehbar; `.trim()` auf beiden Seiten schluckt
|
||
nur Kosmetik. Notiert als Restrisiko: internes CRLF koennte bei
|
||
`core.autocrlf` auf dem Notar-Host einen Fehlalarm ausloesen –
|
||
Verfuegbarkeit, kein Integritaetsloch.
|
||
|
||
- [x] **🔏 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)** (2026-08-18)
|
||
- **R175-01 (HIGH)** – Der Kernsatz des Pentesters: „Append-only allein
|
||
reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung
|
||
signierte zwar, **prüfte aber nie**: Sie las ihre Wahrheit per
|
||
`readFileSync` aus der lokalen **Arbeitsdatei**, nirgends im Repo gab es
|
||
ein `git verify-commit`. Das `-S` war write-only ohne Konsument. Live
|
||
reproduziert: DB-Tail abgeschnitten UND die lokale Ledger-Zeile angepasst →
|
||
„OK, Checkpoint beglaubigt“, exit 0, kein Alarm. Der gekuerzte Zustand
|
||
wurde zur neuen Wahrheit.
|
||
Fix: Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der
|
||
Remote-Kopf); jeder Commit mit Gegenbuch-Aenderung muss eine gueltige
|
||
Signatur tragen; weicht die Arbeitsdatei vom signierten Stand ab, wird
|
||
abgebrochen; lokale, nie gepushte Commits gelten nicht als beglaubigt;
|
||
die Signatur des frisch erzeugten Commits wird gegengeprueft.
|
||
Dazu ein **Pruefmodus** `--check` fuer Auditoren (alle Kontrollen, kein
|
||
Schreibrecht noetig).
|
||
- **R175-02 (MEDIUM)** – `/checkpoint` fuhr je Aufruf ein volles
|
||
`verifyIntegrity()` (O(n), gemessen 0,85 s bei 16k Zeilen) – authentifizierte
|
||
DoS-Verstaerkung. Gebraucht wurde davon nur die Siegel-Wurzel. Jetzt:
|
||
Kopf-Hash direkt aus der Kopfzeile, Wurzel aus dem juengsten gueltigen
|
||
Marker (eine Abfrage + Signaturpruefung), plus `sealLeafCount` statt des
|
||
teuren Status.
|
||
- **R175-03 (LOW→MED)** – `writeFileSync` lief vor dem Commit: schlug der
|
||
signierte Commit fehl, blieb eine verwaiste Zeile liegen, die der
|
||
Folgelauf absorbierte und zementierte. Jetzt Ruecknahme bei Fehlschlag
|
||
(`git checkout`), und `NOTARY_SIGN=false` verlangt zusaetzlich
|
||
`NOTARY_INSECURE_ACK=…` – der Reflex „Cron hakt, Signatur abschalten“
|
||
soll nicht ausreichen.
|
||
- Verifiziert mit **echtem SSH-Signaturschluessel** gegen eine CRM-Attrappe:
|
||
stilles Waschen (DB gekuerzt + lokale Datei gebogen) → Alarm statt „OK“;
|
||
frei erfundene, nie committete Zeile → Alarm; unsignierter Commit in der
|
||
Historie → Alarm; Commit-Fehlschlag → Datei zurueckgerollt, keine
|
||
Geisterzeile; `NOTARY_SIGN=false` ohne Bestaetigung → verweigert;
|
||
Pruefmodus meldet sauber ohne anzuhaengen. Alle Testartefakte geloescht.
|
||
- Seine Bestaetigungen: Key-Trennung im Deploy haelt (`printenv` im
|
||
Container zeigt keinen Notar-Schluessel); `audit:read` kann am
|
||
Checkpoint keine falschen Werte injizieren.
|
||
|
||
- [x] **⚖️ Gegenbuch: externe Notarisierung der Audit-Kette** (2026-08-18)
|
||
- Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in
|
||
derselben Datenbank, die sie absichern sollen – der Pentest hat das ueber
|
||
mehrere Runden Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel-
|
||
Marker selbst. Ein Gegenbuch an einem fremden Ort durchbricht das.
|
||
- Aufteilung nach der Analyse des Pentesters („der Schutz kommt vom ORT,
|
||
nicht von der Signatur“): Das CRM liefert nur einen **lesbaren
|
||
Kontrollwert ohne Geheimnisse** (`GET /api/audit-logs/checkpoint`,
|
||
`audit:read`). Signiert, zeitstempelt und angehaengt wird auf einem
|
||
**anderen Rechner** – Schluessel und Push-Recht liegen NICHT in den
|
||
Deploy-Secrets des CRM. Ohne diese Trennung waere es „D1 nochmal, nur
|
||
schlimmer“: sieht nach doppeltem Boden aus, tut still nichts.
|
||
- Der Kontrollwert enthaelt bewusst `maxId`. Ein blosser Kopf-Hash erkennt
|
||
Umschreiben, aber kein **Abschneiden am Ende** – genau die R174-01-Klasse,
|
||
eine Ebene hoeher. `atId` erlaubt der Gegenstelle, einen frueher
|
||
beglaubigten Kopf erneut abzufragen und nachzurechnen.
|
||
- Gegenstelle: `tools/audit-notary/notary.mjs` (Cron auf zweitem Rechner,
|
||
privates Git-Repo als Append-only-Ablage, signierte Commits). Prueft VOR
|
||
dem Anhaengen und bricht bei Widerspruch mit Exit-Code 2 ab, **ohne zu
|
||
schreiben** – der manipulierte Zustand wird nicht als neue Wahrheit
|
||
festgeschrieben.
|
||
- Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile
|
||
veraendert → Alarm; am Ende abgeschnitten (maxId 5→4) → Alarm; Gegenbuch
|
||
selbst gekuerzt (seq-Luecke) → Alarm; in allen Faellen nichts angehaengt.
|
||
Wegwerf-DB und Test-Gegenbuch danach geloescht.
|
||
- **Ehrlich dokumentiert** (README): Restfenster zwischen zwei Laeufen bleibt
|
||
und ist inhaerent; ein stiller Cron-Ausfall erzeugt im CRM KEINE Warnung
|
||
und muss auf dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss
|
||
serverseitig gesperrt sein, sonst ist Append-only nur geliehen.
|
||
- **Betrieb:** Einrichtung liegt beim Betreiber (privates Repo, Cron,
|
||
Signaturschluessel auf dem zweiten Rechner).
|
||
|
||
- [x] **🚨 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)** (2026-08-18)
|
||
- Fund: Der Siegelzustand hing **ausschliesslich** am Marker im Audit-Log –
|
||
und den kann ein DB-Schreiber **ohne Schluessel** loeschen. Danach meldete
|
||
die Pruefung `kein_siegel`, also **Entwarnung**, ununterscheidbar von „nie
|
||
versiegelt“. Die 11 Blattwerte blieben verwaist liegen und wurden nie
|
||
konsultiert; die zuvor erkannte V1-Faelschung war wieder unsichtbar.
|
||
- Besonders bitter im **Tail-Fall**: Steht der Marker am Ketten-Ende – genau
|
||
der Zustand direkt nach dem einmaligen `seal-backlog` beim Deploy – reisst
|
||
beim Loeschen nicht einmal eine Luecke. Ergebnis war `valid: true`,
|
||
„Alle Einträge unverändert und lückenlos verkettet“, **null Spur**. Meine
|
||
Antwort auf seine Frage (b) war damit falsch: die Luecke reisst nur,
|
||
solange der Marker NICHT am Ende steht.
|
||
- Fix (sein Vorschlag): Gegen-Check „Blaetter vorhanden, aber kein gueltiger
|
||
Marker“ → neuer Status **`entfernt`** statt `kein_siegel`, mit
|
||
ausdruecklicher Meldung („Das ist KEIN Normalzustand“). Zusaetzlich kippt
|
||
ein gebrochenes ODER entferntes Siegel jetzt `valid` auf `false`, auch wenn
|
||
keine einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust
|
||
ohne Wirkung. Und der beruhigende Einstiegssatz entfaellt bei
|
||
Siegelproblemen, damit er die Warnung nicht entwertet.
|
||
- Verifiziert ueber den echten HTTP-Pfad, exakt sein Szenario 4: Marker war
|
||
nachweislich das Ketten-Ende, Marker + Middleware-Decoy geloescht,
|
||
**0 Ketten-Luecken** – Ergebnis jetzt `valid: false`, Status `entfernt`,
|
||
Klartext-Warnung. Zusaetzlich der Nicht-Tail-Fall geprueft. Zwei
|
||
Wegwerf-Datenbanken, beide danach geloescht; Dev unberuehrt.
|
||
`tsc` + `vite build` gruen.
|
||
- Seine Gegenchecks bestaetigt: Frage (a) Middleware kann keine Marker-/
|
||
Manifest-Signatur mit auswertbarem Inhalt nachbilden; Frage (c)
|
||
Re-Insert mit derselben id umgeht nichts, weil das Blatt alle Spalten
|
||
deckt.
|
||
|
||
- [x] **🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM)** (2026-08-18)
|
||
- **R173-01 (HIGH) – Siegel war ueber HTTP tot.** Die generische
|
||
`auditMiddleware` protokolliert den `POST /seal-backlog`-Request SELBST als
|
||
`AuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog` – exakt die
|
||
Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohne
|
||
`changesAfter`. Der Selektor griff diese Zeile, `root` war `undefined`,
|
||
Ergebnis: **dauerhaft „gebrochen“ bei null manipulierten Zeilen.** Das
|
||
komplette R171-02-Deliverable war damit im Betrieb wertlos.
|
||
**Mein Testfehler:** Ich hatte `sealBacklog()`/`verifyIntegrity()` direkt
|
||
aufgerufen, nie ueber HTTP – die Middleware lief nie mit. Dieselbe
|
||
Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar).
|
||
Fix: eigener Ressourcentyp `AuditBacklogSeal`, den die Middleware nie
|
||
vergibt (sie leitet ihn aus dem Pfad ab → `AuditLog`), zusaetzlich muss der
|
||
Marker auswertbares `{toId, root}` tragen.
|
||
- **R173-02 (MEDIUM) – Siegel war blind fuers Loeschen.** `if (!zeile)
|
||
continue` uebersprang fehlende Zeilen; das Loeschen eines gesiegelten
|
||
Einbruchsbelegs erschien nur als „unerklaerte Luecke“, waehrend der
|
||
dedizierte Indikator „intakt“ meldete – Entwarnung, waehrend Beweismaterial
|
||
verschwindet. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit
|
||
eigener Liste (`backlogMissing`) und werden namentlich gemeldet. Deckt
|
||
zugleich seine Frage (a) ab: geprueft wird jetzt die gesiegelte Menge
|
||
gegen die Live-Population, nicht nur der Inhalt vorhandener Zeilen.
|
||
- **R173-03 (MEDIUM) – Re-Seal wusch mit weniger Spur als ein Rehash.** Die
|
||
R170-01-Haertung (Vorbefund im Marker) war auf `seal-backlog` nie
|
||
angewandt. Fix: Der Siegel-Marker haelt jetzt den Befund VOR dem Siegeln
|
||
fest (manipulierte/fehlende Zeilen, Ketten-Luecken) samt Status und Wurzel
|
||
des vorherigen Siegels; `/verify` weist die Anzahl gueltiger Siegel aus und
|
||
weist bei >1 ausdruecklich darauf hin.
|
||
- Verifiziert **ueber den echten HTTP-Pfad inkl. Middleware** (separate
|
||
Wegwerf-DB, Mischbestand 6×V1/4×V2/4×V3): Middleware-Protokollzeile ist
|
||
vorhanden, Status trotzdem `intakt`; Loeschen einer gesiegelten Zeile →
|
||
`gebrochen`, `backlogMissing:[2]`, namentlich in der Meldung; erneutes
|
||
Siegeln → Anzahl 2 gemeldet, Marker enthaelt „vorheriges Siegel: gebrochen,
|
||
Befund fehlend [2]“. Wegwerf-DB geloescht, Dev unberuehrt.
|
||
`tsc` + `vite build` gruen.
|
||
|
||
- [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18)
|
||
- Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur
|
||
7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine
|
||
`LOGIN_FAILED`-Zeile auf `success=1` setzen, das Label auf
|
||
„…hat sich angemeldet“ aendern und `errorMessage` leeren – **alles
|
||
Nicht-Hash-Felder, Hash unveraendert** → `/verify` meldete `valid=true`.
|
||
Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar.
|
||
- Rueckwirkend signieren geht nicht; ein Rehash waere die falsche Medizin.
|
||
Stattdessen **Bestandssiegel**: einmalig, nicht destruktiv, schreibt nichts
|
||
um. Je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten
|
||
Marker.
|
||
- Umgesetzt exakt nach seinen vier Bedingungen:
|
||
1. **Blaetter ueber den vollen Zeileninhalt** (alle Spalten inkl. `id` und
|
||
`hashVersion`), NICHT ueber den 7-Feld-V1-Hash – sonst lebte die Luecke
|
||
im Siegel weiter.
|
||
2. **Wurzel signiert** – sie steht im Marker, der selbst Version 3 und
|
||
HMAC-signiert ist. Ohne `AUDIT_HMAC_KEY` wird das Siegeln abgelehnt.
|
||
3. **Bereich fix auf `[1 … v3FromId-1]`** gebunden statt per Live-Abfrage
|
||
`hashVersion < 3`. Sein Umgehungsweg: ein Up-Flip der Grenzzeile
|
||
(V1→V2, schluessellos nachrechenbar) haette sie sonst aus der geprueften
|
||
Menge gedraengt – sie waere nur als harmlose Luecke erschienen.
|
||
4. **Pruefung je id**: vorhanden, weiterhin Altbestand, Inhalt == Blatt.
|
||
Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand
|
||
auffliegt.
|
||
- Naht geschlossen: Zeilen ab `v3FromId` MUESSEN laut Versionsgrenze V3
|
||
sein, es bleibt also keine ungesiegelte Zone dazwischen.
|
||
- Neuer Endpunkt `POST /audit-logs/seal-backlog` (`audit:admin`,
|
||
`{"confirm":"SEAL"}`). `/verify` meldet den Siegelzustand im Klartext –
|
||
auch wenn **kein** Siegel existiert („Aenderungen daran waeren nicht
|
||
erkennbar“).
|
||
- Verifiziert in einer separaten Wegwerf-Datenbank mit realistischem
|
||
Mischbestand (6×V1, 5×V2, 5×V3): ohne Siegel ist der Angriff unsichtbar
|
||
(`valid=true`) – mit Siegel wird er erkannt und die Zeile benannt (`[3]`);
|
||
sein Up-Flip der Grenzzeile wird ebenfalls erkannt (`[6]`). Wegwerf-DB
|
||
danach geloescht, Dev-Daten unberuehrt. `tsc` + `vite build` gruen.
|
||
|
||
- [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18)
|
||
- **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten
|
||
bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert
|
||
und gueltig ist. Das Manifest steht in `changesAfter`, und dieses Feld ist
|
||
erst ab Hash-Version 2 mitgehasht, auf **V1-Altzeilen also voellig
|
||
unauthentifiziert**. Ein Angreifer konnte in eine BELIEBIGE V1-Zeile ein
|
||
erfundenes Manifest schreiben, ohne deren Hash zu veraendern, und damit
|
||
eigene Loeschungen als „erklaert“ ausweisen – womit zugleich die
|
||
Eskalation an signierten Zeilen ausfiel. Damit waren **Anker UND
|
||
Versionsgrenze** umgangen. Der Pentester hat es live gezeigt; es war genau
|
||
die Stelle, die ich beim Uebergeben als meine schwaechste benannt hatte.
|
||
- Fix: Ein Manifest zaehlt nur noch, wenn seine Traegerzeile (1) laut
|
||
Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert und (2) ihre
|
||
HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. Ohne Schluessel
|
||
gibt es keine gueltigen Traeger – dann bleiben Luecken unerklaert, was die
|
||
sichere Richtung ist.
|
||
- **R171-03:** Eskalierte Luecken standen in `tamperedEntries` UND
|
||
`chainGaps`; `invalidEntries` zaehlte sie doppelt. Jetzt entdoppelt.
|
||
- Verifiziert: boeswillige Loeschung → Befund; erfundenes Manifest in
|
||
herabgestufter Traegerzeile → **ignoriert**, Luecke bleibt Befund und die
|
||
Traegerzeile wird selbst beanstandet; **legitimes signiertes Manifest
|
||
erklaert die Luecke weiterhin** (Funktion nicht kaputt). `tsc` gruen.
|
||
- Hinweis: Auf der Dev-DB existiert kein V1-Bestand mehr (der Rehash-Test aus
|
||
R170-01 hat alles auf V3 gehoben), der Angriff wurde daher mit einer gezielt
|
||
herabgestuften Traegerzeile nachgestellt. Auf Staging/Prod ist der
|
||
V1-Altbestand real vorhanden.
|
||
|
||
- [x] **🧾 rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)** (2026-08-18)
|
||
- Fund: `POST /audit-logs/rehash` (audit:admin) rechnet die Kette MIT dem
|
||
HMAC-Schluessel neu und macht sie damit wieder stimmig – auch wenn sie
|
||
vorher berechtigte Beanstandungen enthielt. Der Anker schuetzt also gegen
|
||
einen DB-Schreiber ohne Schluessel, **nicht** gegen einen Admin mit
|
||
`audit:admin`. Der bisherige Marker hielt nur fest, DASS rehasht wurde,
|
||
nicht WAS dabei verschwand.
|
||
- Fix 1 – **Vorzustand im Marker**: Vor dem Rehash wird `verifyIntegrity()`
|
||
erhoben und samt Kettenkopf (id, hash, hashVersion) im Marker gesichert:
|
||
Anzahl geprueft, Listen der manipulierten Zeilen, der Ketten-Luecken, der
|
||
Luecken ohne dokumentierte Loeschung und der nicht pruefbaren. Dazu
|
||
ausloesender Benutzer und IP (bisher stand dort pauschal „system“).
|
||
Der Marker entsteht nach dem Rehash, ist Teil der neuen Kette und
|
||
signiert – entfernen ginge nur unter Hinterlassung einer Luecke.
|
||
- Fix 2 – **Bestaetigung verlangen**: `rehash` erfordert
|
||
`{"confirm":"REHASH"}`, `cleanup` erfordert `{"confirm":"CLEANUP"}`.
|
||
Der Pentester hatte beide bei blinder Methoden-Erkundung per POST
|
||
unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400.
|
||
- Verifiziert: blinder POST auf beide Endpunkte → 400 ohne Wirkung; mit
|
||
Bestaetigung → Rehash laeuft, Marker enthaelt Ausloeser, Vorbefund
|
||
(1 manipuliert, 7 Luecken mit exakten IDs) und Kettenkopf.
|
||
- **Offengelegt:** Dieser Test hat auf der DEV-Datenbank real rehasht – die
|
||
dortigen historischen Beanstandungen sind rechnerisch geglaettet
|
||
(`valid = true`). Genau der beschriebene Effekt; der Vorzustand steht
|
||
jetzt aber im Marker. Staging/Prod unberuehrt.
|
||
- Offen (Betreiber-Entscheidung): Off-Site-Notarisierung des Kettenkopfes.
|
||
Erst sie deckt den Fall „Admin mit audit:admin“ vollstaendig ab.
|
||
|
||
- [x] **🔒 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18)
|
||
- Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben
|
||
gestellt hatte („laesst sich das Kulanzfenster ausnutzen?“) – und zwar
|
||
nicht per Timing, sondern per **Linien-Fork**: Jede Kulanz-Wiedervorlage
|
||
rotierte auf einen FRISCHEN Token mit eigenem, zurueckgesetztem Zaehler.
|
||
Ein Angreifer mit gestohlenem Token konnte damit aus dem erkennbaren
|
||
Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung entkommen,
|
||
die nie wieder mit der des Opfers kollidiert – **dauerhaft unsichtbar,
|
||
kein einziges CRITICAL**. Damit war die Kern-Garantie von R164-02
|
||
(Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt.
|
||
- Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird
|
||
schon beim Einloesen im selben bedingten UPDATE reserviert
|
||
(`replacedByJti`). Eine Wiedervorlage im Fenster gibt **denselben** bereits
|
||
ausgestellten Nachfolger zurueck, statt neu zu rotieren – ohne neuen
|
||
Datensatz. Parallele Tabs laufen dadurch auf EINE Linie zusammen; wer den
|
||
Token spaeter (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig
|
||
und loest den Familien-Widerruf aus.
|
||
Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert
|
||
(das waere wieder der Fork), sondern fail-closed als Replay gewertet.
|
||
- Verifiziert – sein PoC nachgebaut: T0 legit → TA, T0 replayt → TC;
|
||
**TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs
|
||
beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf
|
||
des Fensters → 401, Familie widerrufen, Angreifer-Linie tot,
|
||
`SUSPICIOUS/CRITICAL` gemeldet.
|
||
- Regression der in R168 bestaetigten Faelle: 40 parallel → 4 erfolgreich
|
||
(1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 1–4
|
||
ok, 5. → Replay; Token ohne jti → fail-closed; Logout widerruft. `tsc` gruen.
|
||
|
||
- [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18)
|
||
- Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis `exp`
|
||
gueltig, ein gestohlener Token also **bis zu 7 Tage parallel** zum legitimen
|
||
nutzbar (der Pentester trug mit EINEM Token 90 Parallel-Requests).
|
||
- Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine `jti`
|
||
und gehoert zu einer Sitzungs-`familyId` (neue Tabelle
|
||
`RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die
|
||
`jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie**
|
||
widerrufen – Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt
|
||
es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet.
|
||
- Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn
|
||
bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis.
|
||
- **Kulanzfenster** fuer parallele Tabs: 15 s UND hoechstens 3
|
||
Wiederverwendungen. Ohne Toleranz wuerde der zweite legitime Tab die
|
||
Sitzung sprengen; die enge Grenze laesst einen Missbrauchs-Burst trotzdem
|
||
auflaufen.
|
||
- **Einloesen ist atomar** (bedingtes UPDATE statt Lesen-dann-Schreiben) –
|
||
derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
|
||
gleichzeitige Requests ausnahmslos durch, weil alle den Token als „noch
|
||
unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war.
|
||
- Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele
|
||
Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest
|
||
widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin
|
||
erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout
|
||
widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an.
|
||
Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` +
|
||
`vite build` gruen.
|
||
- **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy)
|
||
werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW
|
||
gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach
|
||
dem Deploy **einmalig neu anmelden**.
|
||
|
||
- [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18)
|
||
- Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
|
||
Kette selbsttragend – wer die DB schreiben kann, konnte jede Zeile aendern
|
||
und alle Folgehashes konsistent nachziehen, die Pruefung meldete „gueltig“.
|
||
- Version 3 signiert denselben Inhalt per **HMAC-SHA256** mit `AUDIT_HMAC_KEY`
|
||
– einem Schluessel, der NICHT in der Datenbank liegt. Ohne ihn laesst sich
|
||
keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.
|
||
- **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es
|
||
faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar**
|
||
(eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als
|
||
manipuliert – eine fehlende Konfiguration darf kein Fehlalarm ueber das
|
||
gesamte Log sein.
|
||
- **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der
|
||
Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne
|
||
KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig.
|
||
- **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die
|
||
FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit – die
|
||
Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel
|
||
genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein
|
||
`previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke
|
||
vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als
|
||
struktureller Zufall.
|
||
- Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit
|
||
gueltigem V2-Hash auf der **fruehesten** V3-Zeile → erkannt; dasselbe auf
|
||
der **letzten** V3-Zeile (kein Nachfolger) → erkannt; ohne Schluessel
|
||
0 manipuliert / 2 nicht pruefbar; 40 parallele Schreiber → 40/40, 0 Forks,
|
||
alle V3. `tsc` + `vite build` gruen.
|
||
- **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert
|
||
(`openssl rand -hex 32`). Schluessel sichern – geht er verloren, sind die
|
||
damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als
|
||
manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env`
|
||
(gitignored) und ist NICHT der Prod-Schluessel.
|
||
|
||
- [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18)
|
||
- Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02
|
||
selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen
|
||
Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der
|
||
von der Zeile SELBST deklarierten `hashVersion` – und die ist nicht gehasht.
|
||
Angriff: `hashVersion` 2→1 setzen, die nur von V2 abgedeckten Felder
|
||
aendern (`success` false→true, `errorMessage` leeren, `resourceLabel`
|
||
umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder
|
||
nachziehen → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“.
|
||
Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht
|
||
einmal ein Gap → dauerhaft unsichtbar.
|
||
- Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette
|
||
ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab
|
||
dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von
|
||
der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird
|
||
immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das
|
||
Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein
|
||
Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte
|
||
Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall).
|
||
- Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**.
|
||
Neu `unexplainedGaps` – nur Luecken ohne protokollierte Loeschung sind
|
||
erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich
|
||
eine boeswillige Loeschung als „harmloser Gap“ tarnen konnte.
|
||
- Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger →
|
||
**erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**;
|
||
Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**;
|
||
Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert
|
||
(keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen.
|
||
- Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig
|
||
parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze
|
||
entstehen und wuerden angezeigt. Beim hier ueblichen Deploy
|
||
(pull + rebuild + restart) tritt das nicht auf.
|
||
|
||
- [x] **🛡️ Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18)
|
||
- **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die
|
||
Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem
|
||
COMMIT. Im Fenster Release↔Commit las der naechste Schreiber ein noch nicht
|
||
sichtbares Kettenende → zwei Zeilen am selben Vorgaenger. Meine
|
||
„100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken
|
||
zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal.
|
||
Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren
|
||
fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der
|
||
Lesevorgang den frisch festgeschriebenen Stand sieht.
|
||
**Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster:
|
||
Release-vor-Commit → Fork, Zeilensperre → kein Fork.
|
||
- **R166-02 (MEDIUM) – Hash deckte nur 7 Felder.** `changesBefore/After`
|
||
(die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`,
|
||
`dataSubjectId`, `userId`/`customerId` waren NICHT gehasht – ein Einzeledit
|
||
dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) +
|
||
`generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten
|
||
Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2.
|
||
Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/
|
||
dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig.
|
||
- **R166-03 (LOW)** – „kein Cookie“ (normaler Erstbesuch) wurde als
|
||
`HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label
|
||
„ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH.
|
||
- **R166-04 (LOW, pre-existing)** – `GET /retention-policies` wurde von
|
||
`GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der
|
||
Parameter-Route, mit Warnhinweis im Code.
|
||
- **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein
|
||
Loeschungs-Manifest (Bereich `fromId`–`toId`, Anzahl, Policy, Cutoff) als
|
||
eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit
|
||
erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die
|
||
Beweiskraft der Vergangenheit zurueckgesetzt wurde.
|
||
- Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2;
|
||
manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen.
|
||
- **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel
|
||
ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber
|
||
Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers.
|
||
|
||
- [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18)
|
||
- **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig
|
||
unterschiedliche Befunde in einen Topf und meldete beides als
|
||
„N manipulierte Eintraege“. Eine harmlose Verkettungsluecke sah damit aus
|
||
wie ein Angriff – die Meldung war im Alltag nicht deutbar und wurde dadurch
|
||
wertlos (dasselbe Muster wie beim Refresh-Rauschen).
|
||
- Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich
|
||
veraendert – **ernst**) und `chainGaps` (Verkettung unterbrochen durch
|
||
parallele Schreibvorgaenge oder geloeschte Zeilen – **meist harmlos**)
|
||
erweitert. `invalidEntries` bleibt als Summe erhalten
|
||
(Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend
|
||
eindeutig; Frontend-API-Typ nachgezogen.
|
||
- **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes
|
||
als `Authentication / LOW`. Diese Kombination traf auf keine spezifische
|
||
Regel und fiel in die Auffangregel `*` mit 3650 Tagen – das **Rauschen
|
||
waere 10 Jahre** aufbewahrt worden, echte Logins nur 2 (730 Tage).
|
||
- Fix: Regel `Authentication / LOW` → 90 Tage. Als Migration
|
||
(`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit
|
||
sie sowohl bestehende Installationen als auch Neuinstallationen erreicht.
|
||
- Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine**
|
||
Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise
|
||
bleiben bewusst CRITICAL. Ein Herabstufen „fuer eine ruhigere Liste“ wuerde
|
||
still die Aufbewahrungsfrist verlaengern – daher unterlassen.
|
||
- Verifiziert: Live-Test gegen Dev-DB – echte Manipulation einer Zeile
|
||
(`UPDATE … SET userEmail`) wird als **manipuliert** erkannt und nicht mit
|
||
Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt
|
||
wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen.
|
||
|
||
- [x] **🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18)
|
||
- `createAuditLog` las den Vorgaenger-Hash und schrieb den neuen Eintrag als
|
||
zwei getrennte Schritte. Zwei parallele Requests lasen denselben letzten
|
||
Hash und haengten sich beide daran → Kette zerrissen (echte Bruchstellen im
|
||
Bestand: 05.05./07.05.2026).
|
||
- Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen
|
||
benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB,
|
||
wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`,
|
||
da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der
|
||
Verbindung zurueck in den Pool und blockiert alle Schreiber).
|
||
- **Verworfener erster Ansatz – wichtig:** `SELECT … FOR UPDATE` auf das
|
||
Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs
|
||
kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch
|
||
Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER
|
||
Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer
|
||
Kettenbruch – deshalb der Umbau auf den benannten Lock.
|
||
- Verifiziert: 100 parallele Schreiber → **100/100 geschrieben, 0 neue
|
||
Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei
|
||
(kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7
|
||
historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst
|
||
VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen.
|
||
|
||
- [x] **🛡️ Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18)
|
||
- Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630**
|
||
Zeilen als „manipuliert“. Davon waren **3100 Fehlalarme** – eingegrenzt auf
|
||
exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 08.02.–01.05.2026.
|
||
- Ursache: Der R121-Fix nahm an, `resourceId` sei beim Schreiben immer
|
||
`undefined` gewesen (Key faellt bei `JSON.stringify` weg) und daher wuerden
|
||
**alle** Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 –
|
||
aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war DRIN.
|
||
- Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos
|
||
ausloest, wird ignoriert – **echte** Manipulation ginge im Lärm unter
|
||
(gleiches Muster wie beim Refresh-Rauschen, [R165]).
|
||
- Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten;
|
||
`verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
|
||
wenn die aktuelle Variante nicht passt). **Kein Rehash** – der waere der
|
||
naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der
|
||
Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes
|
||
bleiben unangetastet.
|
||
- Verifiziert: ungueltige Zeilen **3107 → 7** (die 7 sind echte Ketten-Brueche,
|
||
siehe naechster Punkt). Adversarial gegengetestet: Manipulation an
|
||
`userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie
|
||
NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen
|
||
akzeptiert. `tsc` gruen.
|
||
|
||
- [x] **🐛 Audit-Log: Pfad-Matching kaputt – Auth-Actions generisch (Pentest R165-01)** (2026-08-18)
|
||
- Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** – jeder
|
||
`/refresh` weiter `CREATE / CRITICAL / „Anmeldung erstellt“`. Zusatzbefund:
|
||
auch `/login` und `/logout` liefen als generisches `CREATE`.
|
||
- **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter
|
||
Code**: `auditMiddleware` liest `req.path` erst im `res.on('finish')`-Handler.
|
||
Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url` und stellt
|
||
ihn nur beim `next()`-Durchlauf wieder her – ein terminaler Handler
|
||
(`res.json()`) ruft nie `next()`, also bleibt `req.path` router-relativ
|
||
(`/refresh` statt `/api/auth/refresh`). Alle `path.includes('/auth/...')`-Checks
|
||
liefen ins Leere → Fallback POST→CREATE + Default-Sensitivität CRITICAL.
|
||
Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` → FINISH `/refresh`).
|
||
- Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED`
|
||
waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das
|
||
`endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie
|
||
betroffen (eigene `emit()`-Calls) – daher funktionierte das Alerting korrekt.
|
||
- Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten
|
||
(`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream
|
||
ausschließlich diesen nutzen – in `determineAction`, `generateHumanLabel`,
|
||
`extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH`
|
||
zusätzlich in die „immer loggen“-Ausnahme aufgenommen.
|
||
- Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests):
|
||
`TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag),
|
||
`LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem
|
||
`endpoint`-Pfad und korrekten Labels. `tsc` grün.
|
||
|
||
- [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)
|