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. 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, zeitgestempelt 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. 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. Ehrlich dokumentiert: 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 KiB
📋 OpenCRM – Todo-Liste
🔜 Offen
Manuelle Tests (vor Release durchklicken)
Checklisten für Security + Email-Log-System stehen in 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
tenantIdim 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.devia 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 lassenpayment_failed→ Banner im CRM, nach X Tagen pausierenmandate_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):
- Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain)
- Admin-Portal: Trial-Instanz starten
- DB erstellen, Docker-Container hochfahren, Caddy-Config für Subdomain
- Einladungs-Email mit Admin-Login + Passwort-Reset-Link
- Tag 25: Erinnerungs-Email "Deine Trial läuft bald ab"
- Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren"
- Kunde erfasst GoCardless-Mandat im Admin-Portal-Login
- Bei erfolgreicher Zahlung: Instanz bleibt aktiv
- 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.ymlpro 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
-
⚖️ 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.atIderlaubt 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).
-
🚨 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-backlogbeim Deploy – reisst beim Loeschen nicht einmal eine Luecke. Ergebnis warvalid: 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
entferntstattkein_siegel, mit ausdruecklicher Meldung („Das ist KEIN Normalzustand“). Zusaetzlich kippt ein gebrochenes ODER entferntes Siegel jetztvalidauffalse, 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, Statusentfernt, Klartext-Warnung. Zusaetzlich der Nicht-Tail-Fall geprueft. Zwei Wegwerf-Datenbanken, beide danach geloescht; Dev unberuehrt.tsc+vite buildgruen. - 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.
- Fund: Der Siegelzustand hing ausschliesslich am Marker im Audit-Log –
und den kann ein DB-Schreiber ohne Schluessel loeschen. Danach meldete
die Pruefung
-
🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM) (2026-08-18)
- R173-01 (HIGH) – Siegel war ueber HTTP tot. Die generische
auditMiddlewareprotokolliert denPOST /seal-backlog-Request SELBST alsAuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog– exakt die Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohnechangesAfter. Der Selektor griff diese Zeile,rootwarundefined, Ergebnis: dauerhaft „gebrochen“ bei null manipulierten Zeilen. Das komplette R171-02-Deliverable war damit im Betrieb wertlos. Mein Testfehler: Ich hattesealBacklog()/verifyIntegrity()direkt aufgerufen, nie ueber HTTP – die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar). Fix: eigener RessourcentypAuditBacklogSeal, 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) continueuebersprang 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-backlognie 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;/verifyweist 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 buildgruen.
- R173-01 (HIGH) – Siegel war ueber HTTP tot. Die generische
-
🧿 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 aufsuccess=1setzen, das Label auf „…hat sich angemeldet“ aendern underrorMessageleeren – alles Nicht-Hash-Felder, Hash unveraendert →/verifymeldetevalid=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:
- Blaetter ueber den vollen Zeileninhalt (alle Spalten inkl.
idundhashVersion), NICHT ueber den 7-Feld-V1-Hash – sonst lebte die Luecke im Siegel weiter. - Wurzel signiert – sie steht im Marker, der selbst Version 3 und
HMAC-signiert ist. Ohne
AUDIT_HMAC_KEYwird das Siegeln abgelehnt. - Bereich fix auf
[1 … v3FromId-1]gebunden statt per Live-AbfragehashVersion < 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. - Pruefung je id: vorhanden, weiterhin Altbestand, Inhalt == Blatt. Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand auffliegt.
- Naht geschlossen: Zeilen ab
v3FromIdMUESSEN laut Versionsgrenze V3 sein, es bleibt also keine ungesiegelte Zone dazwischen.
- Blaetter ueber den vollen Zeileninhalt (alle Spalten inkl.
- Neuer Endpunkt
POST /audit-logs/seal-backlog(audit:admin,{"confirm":"SEAL"})./verifymeldet 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 buildgruen.
- Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur
7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine
-
🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW) (2026-08-18)
- R171-01:
verifyIntegrityvertraute Loeschungs-Manifesten bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert und gueltig ist. Das Manifest steht inchangesAfter, 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
tamperedEntriesUNDchainGaps;invalidEntrieszaehlte 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).
tscgruen. - 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.
- R171-01:
-
🧾 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 mitaudit: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:
rehasherfordert{"confirm":"REHASH"},cleanuperfordert{"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.
- Fund:
-
🔒 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/CRITICALgemeldet. - 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.
tscgruen.
-
🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02) (2026-08-18)
- Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis
expgueltig, 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
jtiund gehoert zu einer Sitzungs-familyId(neue TabelleRefreshTokenRecord, Migration20260818160000). Beim Einloesen wird diejtiverbraucht; taucht sie erneut auf, wird die gesamte Familie widerrufen – Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt es und der Vorfall wird alsSUSPICIOUS / CRITICALgemeldet. - 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
jtiin 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 kommtSUSPICIOUS/CRITICALan. Audit-Regression unveraendert (25/25, 0 Forks, alle V3).tsc+vite buildgruen. - 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.
- Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis
-
⚓ 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_OLDwird 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, seinpreviousHashist 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 buildgruen. - Betrieb:
AUDIT_HMAC_KEYin.env.exampledokumentiert (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.
-
🔒 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):
verifyIntegritywaehlte die Pruefstaerke nach der von der Zeile SELBST deklariertenhashVersion– und die ist nicht gehasht. Angriff:hashVersion2→1 setzen, die nur von V2 abgedeckten Felder aendern (successfalse→true,errorMessageleeren,resourceLabelumschreiben) 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 buildgruen. - 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.
- 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):
-
🛡️ 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
finallyINNERHALB 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 MutexAuditChainLock+FOR UPDATE; InnoDB-Zeilensperren fallen erst beim COMMIT. DazuisolationLevel: 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/customerIdwaren NICHT gehasht – ein Einzeledit dort blieb unsichtbar. Fix:hashVersion(Migration20260818150000) +generateHashV2ueber alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash gueltig.rehashAllschreibt 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 / abgelehntgefuehrt. Jetzt eigener Ausgang: LOW + Label „ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH. - R166-04 (LOW, pre-existing) –
GET /retention-policieswurde vonGET /:idverschluckt. Konkrete Routen jetzt konsequent vor der Parameter-Route, mit Warnhinweis im Code. - Design-Empfehlungen umgesetzt:
runRetentionCleanupschreibt ein Loeschungs-Manifest (BereichfromId–toId, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit erklaerungsbeduerftig;rehashAllschreibt 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 buildgruen. - 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.
- R166-01 (HIGH) – Kette forkte weiter. Mein GET_LOCK-Ansatz gab die
Sperre im
-
🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth (2026-08-18)
- Problem 1 (Deutbarkeit):
verifyIntegritywarf 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) undchainGaps(Verkettung unterbrochen durch parallele Schreibvorgaenge oder geloeschte Zeilen – meist harmlos) erweitert.invalidEntriesbleibt 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 perON 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 buildgruen.
- Problem 1 (Deutbarkeit):
-
🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests) (2026-08-18)
createAuditLoglas 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 imfinally, 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 UPDATEauf das Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren. Gemessen: 38 von 40 parallelen Eintraegen gingen durch Deadlocks verloren (vomcatchstill 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.tscgruen.
-
🛡️ Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben (2026-08-18)
- Beim Nachpruefen aufgefallen:
verifyIntegritymeldete 3107 von 4630 Zeilen als „manipuliert“. Davon waren 3100 Fehlalarme – eingegrenzt auf exakt die Zeilen mitresourceId = NULLaus dem Zeitraum 08.02.–01.05.2026. - Ursache: Der R121-Fix nahm an,
resourceIdsei beim Schreiben immerundefinedgewesen (Key faellt beiJSON.stringifyweg) und daher wuerden alle Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 – aeltere Zeilen wurden mit explizitemnullserialisiert, 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;verifyIntegrityakzeptiert 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/resourceIdwird bei ALTEN wie NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen akzeptiert.tscgruen.
- Beim Nachpruefen aufgefallen:
-
🐛 Audit-Log: Pfad-Matching kaputt – Auth-Actions generisch (Pentest R165-01) (2026-08-18)
- Pentester meldete: Entrauschung (
de0d6bd) live nicht wirksam – jeder/refreshweiterCREATE / CRITICAL / „Anmeldung erstellt“. Zusatzbefund: auch/loginund/logoutliefen als generischesCREATE. - Kein Deploy-Miss (Alerting aus
d599eb3lief ja live), sondern toter Code:auditMiddlewareliestreq.patherst imres.on('finish')-Handler. Express strippt beim Router-Dispatch den Mount-Prefix ausreq.urlund stellt ihn nur beimnext()-Durchlauf wieder her – ein terminaler Handler (res.json()) ruft nienext(), also bleibtreq.pathrouter-relativ (/refreshstatt/api/auth/refresh). Allepath.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_FAILEDwaren im Audit-Stream seit jeher kaputt (pre-existing), ebenso dasendpoint-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie betroffen (eigeneemit()-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 – indetermineAction,generateHumanLabel,extractDataSubjectId,manuallyLoggedPathsundendpoint.TOKEN_REFRESHzusä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 vollemendpoint-Pfad und korrekten Labels.tscgrün.
- Pentester meldete: Entrauschung (
-
🛡️ Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01) (2026-08-18)
- Folgefund zum Entrauschen:
determineActiongab/auth/refreshbedingungslosTOKEN_REFRESH/LOW → ein fehlgeschlagener Refresh (Replay/Brute-Force auf geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der Alarmierung (Angreifer weicht von/loginauf/refreshaus, um unter CRITICAL zu bleiben). - Wichtig: Audit-Actions speisen die Alert-Engine NICHT (die zählt
SecurityEvent-Zeilen viaemit()). Der Tester-Minimalvorschlag (Action →LOGIN_FAILED) hätte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen:- Detection:
refresh()-Catch emittiert jetztTOKEN_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.servicereicht dafürerr.codeREFRESH_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).
- Detection:
- Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH;
tscgrü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.
- Folgefund zum Entrauschen:
-
🔇 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 alsCREATE/ „Anmeldung erstellt" / CRITICAL / Useranonymousgeloggt → sah aus wie anonyme Login-Flut, war aber die eigene Session. - Neuer
AuditAction-WertTOKEN_REFRESH(Enum-Migration20260818120000_audit_token_refresh_action, idempotentesMODIFY COLUMN).determineAction()erkennt/auth/refresh→ eigene Action; Label „Sitzung verlängert (Token erneuert)"; Sensitivität in der Middleware explizit auf LOW (statt DefaultAuthentication → CRITICAL). - Frontend
AuditLogs.tsx: Filter-Option „Sitzung verlängert" + dezente Badge-Farbe (slate); Typ-Union ergänzt.anonymousbleibt (Endpoint läuft ohneauthenticate-Middleware, authentifiziert per Cookie im Service) – bewusst nicht geändert (Option 1). - Verifiziert: Migration auf Dev-DB aktiv,
tsc+vite buildgrün.
- Automatische
-
🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01) (2026-08-18)
createContract/updateContractspreadetenenergyDetails/tvDetails/carInsuranceDetails/mobileDetails(via...mobileData) roh an Prisma → injizierteid/contractIdkonnten 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.internetwar 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).
-
📋 Aufgaben ohne Kunde/Vertrag anlegbar (2026-08-18)
ContractTask.contractIdnullable (Migration20260818110000). Neuer EndpointPOST /tasks(staff-only,contracts:update) für allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag → kein Kunde → nie im Portal sichtbar (visibleInPortalserverseitig 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).
-
📧 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/emailUsesDomainim emailProvider-Service.
- Private Kunden-E-Mail (
-
⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwünscht" (2026-08-18)
EnergyContractDetails.noBonusDesired(Boolean, Migration20260818100000). Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail.
-
🔌 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 (bestehendeEnergyContractDetails.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 seitcb21a2cnicht mehr. Address-Whitelist jetzt per Pick-Helper programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus).
- MaLo-ID gehört zur (Liefer-)Adresse, nicht zum Vertrag. Adresse bekommt
zwei Felder:
-
🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene) (2026-08-13)
- Letzter Spread-Endpunkt (
createContract/updateContractspreadeten rohen...contractDataan Prisma) auf eine Feld-Whitelist umgestellt – konsistent zur R156-Härtung (BankCard/Address/Document).id/contractNumber/createdAt/updatedAt/portalPasswordEncryptedund allecancellation*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/cancellationLetterPathwerden ignoriert.
- Letzter Spread-Endpunkt (
-
🗂️ 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, Migration20260813200000_contract_status_cancelled_confirmed(idempotentesMODIFY COLUMN). Daten-Migration: bestehendeCANCELLED(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_CONFIRMEDin 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.
- Bisheriges „Gekündigt" umbenannt in „Gekündigt / Bestätigung abwarten"
(Status
-
🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155) (2026-08-13)
- Controller reichten rohen
req.bodyan Prisma durch →customerId(Owner) undid(PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber Integritätsschwäche – und mitcardNumberliegt 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-pickBankCardFieldsfü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.
- Controller reichten rohen
-
🪪 Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer (2026-08-13)
- Schema: neues Feld
BankCard.cardNumber(String?, optional). Migration20260813100000_bank_card_number(ADD COLUMN IF NOT EXISTS), auf Dev angewandt +prisma generate. Prod zieht viamigrate deployim 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.
- Schema: neues Feld
-
📄 PDF-Viewer-Modal für Bankkarte-/Ausweis-Dokument (2026-08-13)
- Neue wiederverwendbare Komponente
PdfViewerModal(Modal +<iframe>aufviewUrl(documentPath), inkl. „In neuem Tab" + „Herunterladen"). Nutzt den bestehenden/api/files/download-Endpoint (Per-File-Ownership-Check) – kein neuer Zugriffspfad.<iframe>erlaubt via CSPdefault-src 'self'(object-src 'none'bleibt, daher iframe statt embed/object). - Vertragsansicht (ContractDetail): Button (FileText-Icon) im Card-Header
von „Bankkarte"/„Ausweis" – nur wenn
documentPathgesetzt. - Vertrag bearbeiten (ContractForm): Button neben dem Label „Bankkarte"/
„Ausweis" – nur wenn die aktuell gewählte Karte/Ausweis ein
documentPathhat (e.preventDefault/stopPropagation, da im<label>).
- Neue wiederverwendbare Komponente
-
📇 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.cardUserder angezeigten SIM, nur wenn gesetzt) und Netz (mobileNetwork→ Telekom/Vodafone/Telefónica (o2)) inline hinter der Rufnummer. - Verträge mit Kündigungsbestätigung:
cancellationConfirmationDateals eigene Zeile in roter, fetter Schrift („Kündigungsbestätigung zum …"). - Backend:
getContractTreeForCustomer+getAllContractsliefern jetztmobileNetwork+cardUser(Tree zusätzlichcancellationConfirmationDate; Flat-Liste hat Scalars ohnehin via include). Shared-HelpergetContractTypeInfoumcardUser/networkerweitert (+mobileNetworkLabel). - 3 Render-Pfade ergänzt (waren getrennt!): Kundenakte-Baum, die
Staff-Tabelle in
/contracts(flachegetAll-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.
- In beiden Vertragslisten (Kundenakte-Baum + Hauptmenü
-
🛡️ Globaler API-Rate-Limit-Backstop + BLZ-Guard-Härtung (Pentest R148) (2026-08-12)
- Backstop: Neuer genereller Limiter auf ALLE
/api-Requests (apiBackstopRateLimiter, inindex.tsvor 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 EnvAPI_RATE_LIMIT_PER_MIN(Default 1200/min, Floor 60),/api/healthausgenommen. 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 →lookupBlzliefertbic:''.
- Backstop: Neuer genereller Limiter auf ALLE
-
🔄 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-germanycurrent.json + next.json), gespeichert im Bind-Mount-VolumeBANKDATA_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-KeysblzAutoUpdateEnabled(default true),blzUpdateIntervalDays(default 30) in Whitelist + validiert. - Damit entfällt die manuelle
npm update-Wartung aus dem vorherigen Punkt.
- Neue Einstellungen-Seite Einstellungen → Bankdaten (BLZ) (
-
🏦 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) +ibantoolsfü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+ibantoolsmüssen ~quartalsweise pernpm updateaktualisiert werden (Bundesbank-BLZ ändert sich mehrmals/Jahr).
-
📌 Update-Banner klebt oben fest (sticky) (2026-08-12)
- „Neue Version verfügbar"-Banner scrollte weg; jetzt
stickyan 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.
- „Neue Version verfügbar"-Banner scrollte weg; jetzt
-
📋 Hauptmenü: Gutschriften/Lieferscheine-Gesamtübersicht (2026-08-12)
- Neuer Menüpunkt „Gutschriften" (Sidebar,
show: contracts:read) → Seite/credit-notesmit 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 viahasAuthorization).customerIdskommt aus dem JWT (nicht aus Query/Body → nicht manipulierbar). Für Portal wirdreceiptPathaus 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).
- Neuer Menüpunkt „Gutschriften" (Sidebar,
-
🚫 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:
createCreditNotewirft 400, wenn wederbillingAddressIdnochaddressIdgesetzt ist.getCreditNoteDefaultslieferthasRecipientAddressfürs Frontend. Verifiziert.
-
🧾 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.
ReceiptControlsin der Liste für Geld UND Sachwert (Label „Überweisungsbeleg" bzw. „Unterschr. Dokument"). Endpoint/upload/credit-notes/:id/receiptwar 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).
- Beleg-Upload jetzt für beide Arten: bei Geld die Überweisungsbestätigung,
bei Sachwert das unterschriebene Dokument.
-
📦 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
effectiveNumberje 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", Dateinamelieferschein-…. - Verifiziert: Sachwert 0 → LS-Nr, GS-Zähler unberührt; Geld → GS-Nr; Übergänge behalten Nummern (kein Neuverbrauch).
-
🔐 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):
getCustomerPortalPasswordliefert jetzt{status: ok|none|desync}und prüft den entschlüsselten Klartext perbcrypt.comparegegen den Login-Hash. Beidesync(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).
-
🎁 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:
amountleer → 0; Sachwert erlaubt 0, Geld verlangt Betrag > 0 (400 sonst).vatRelevantwird 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:
numberist jetzt nullbar (MigrationMODIFY … 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).
-
🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal (2026-08-07)
- Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit
previousContractIdan – der Verlust passierte im Frontend: das „Vorgänger-Vertrag"-Dropdown holte die Verträge übergetAll, 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.getAllumincludeDeactivatederweitert; 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.
CustomerInfoModalnimmt optionale Props (deliveryAddress/billingAddress/contractStressfreiEmailId), Aufrufe aus ContractDetail + ContractForm übergeben den Vertragskontext.
- Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit
-
🚫 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 FunktionmaybeCancelOnCancellationConfirmation(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
cancellationConfirmationsim Cockpit-Result (weil bereits CANCELLED-Verträge mangels „Issue" sonst nicht auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert. - Nachbesserungen Pentest R138:
endDatewird bei DRAFT nicht mehr gesetzt (Entwurf = Vorlage); nur ACTIVE/CANCELLED bekommen Vertragsende = Kündigungsdatum.cancellationConfirmationDate/-OptionsDateim Update-Pfad übervalidateOptionalIsoDatenormalisiert → 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-Typcontract-staff, Portal 403); das Gutschrift-PDF (credit-notes) bleibt vertragsbasiert (eigener Kunde darf seine Gutschrift laden). Anlegen/Beleg-Upload für Portal war bereits perblockPortal/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.tskonsolidiert (deleteFileAbsolute+deleteUploadByRelativePath), ersetzt die vorher 3× kopiertendeleteFileIfExists/cleanupFile(creditNote-, upload-, customer-Service). Reihenfolge umgestellt: erst DB-Delete/-Update, DANN Datei löschen (schlägt DB fehl, bleibt die Datei). Verifiziert.
- 1) Auto-Status bei Kündigungsbestätigung: Wird zu einem Vertrag eine
Kündigungsbestätigung hinzugefügt – als Datum (
-
[~] 🧾 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/FIRMAaus 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 mitSELECT … FOR UPDATE(keine Doppel- vergabe). BeispielGS-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-DefaultvatRelevantwird 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):
CreditNotesSectionim 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- rangemit 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: ". Section-Zeile zeigt das Auszahlungskonto. - Phase 3b Teil 2 (erledigt): ZUGFeRD/Factur-X.
zugferd.service.tserzeugt CII-XML (EN 16931urn:cen.eu:en16931:2017, Typ 381; Kategorie S bei USt, sonst E + Befreiungsgrund).zugferdPdf.service.tsbettet 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 unterbackend/assets/fonts, Pflicht für PDF/A). Dockerfile kopiertbackend/assetsins 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/xmlvsapplication/xml) ggf. nach dem ersten Validator-Lauf nachziehen. - Offen (fachlich): USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
Steuerberater klären – Modell/XML decken beide über
vatRelevantab. ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen.
-
📄➕ 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 (keinpreviousContractId-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 (
ContractFormcopy-Mode + Button inContractDetail); kein neuer Endpoint – nutzt bestehendes Create. Blauer Hinweis-Banner „Kopie … neuer eigenständiger Vertrag" im Formular.
- „Kopieren"-Button in der Vertragsansicht (
-
[~] 🖱️ 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-
DownloadURLmit 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
PdfDragButtongelö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.).
- Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer
ziehen. Über mehrere Iterationen durchgespielt (Chromium-
-
📋 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).
-
📧 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 (
updateMailboxPassworddirekt nach dem Provisioning). - Behebt den Fall, dass die Adresse beim Provider bereits als reine
Weiterleitung existierte: dann kehrte
provisionEmailWithMailboxfrü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. JetztenableMailboxForExistingEmail(-mailbox true -passwd ...), deckt beide Fälle idempotent ab. - Tab-Leiste bei vielen Tabs:
flex-wrapstatt Overflow aus dem Rahmen. - Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag (s.u.).
- Beim Anlegen einer Adresse mit „Echte Mailbox erstellen (IMAP/SMTP)"
wird jetzt immer ein frisch generiertes Passwort explizit am Provider
gesetzt (
-
🤝 Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)
- Neuer Tab vor „Datenschutz", nur Mitarbeiter/Admin (nicht Portal),
ohne Consent-Pflicht nutzbar. Zwei Abschnitte:
- „ wurde an Board geholt durch:" – max. 1 Werber
(DB-Unique auf
recruitedId). - „ hat folgende Kunden an Board geholt:" – beliebig viele.
- „ wurde an Board geholt durch:" – max. 1 Werber
(DB-Unique auf
- 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).
- Neuer Tab vor „Datenschutz", nur Mitarbeiter/Admin (nicht Portal),
ohne Consent-Pflicht nutzbar. Zwei Abschnitte:
-
🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)
- Cache-Header waren schon optimal (index.html
no-store, Assetsimmutable) – aber eine laufende SPA holt sich nach einem Deploy nicht von selbst neuen Code. NeueruseVersionCheck-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).
- Cache-Header waren schon optimal (index.html
-
👁 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 beiisCustomerPortal, reichtincludeDeactivatedan diegetTreeForCustomer-Queries durch (showDeactivatedim 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.
- Der Admin-/Mitarbeiter-Blick (CustomerDetail) hatte den Toggle schon;
jetzt auch in der Kundenportal-Vertragsübersicht (
-
🔒 Mobilfunknetz: server-seitige Whitelist (Pentest-INFO)
mobileNetworkakzeptierte serverseitig jeden (stripHtml-bereinigten) String – das Frontend-Dropdown beschränkte nur clientseitig. Jetzt WhitelistTELEKOM|VODAFONE|TELEFONICAincontract.service.ts(normalizeMobileNetwork), angewandt in Create- UND Update-Pfad (Update-Spread wurde vorher ungefiltert durchgereicht). Unbekannte/ leere Werte → null.
-
🔒 Rate-Limiting: IPv6-Bypass-Härtung (ipKeyGenerator)
- Die Rate-Limiter mit eigenem
keyGenerator(loginRateLimiter,staffPasswordReAuthLimiter) keyten auf die vollereq.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.ipin beidenkeyGeneratordurchipKeyGenerator(...)(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.)
- Die Rate-Limiter mit eigenem
-
🐞 Spam-Tab: Anhänge aus Junk-Ordner (Pentest R124-Fund)
- Beim Spam-Feature wurden
moveEmailToTrash/restoreEmailFromTrashauf den echten Junk-Pfad umgestellt, aber vier Attachment-Funktionen im Controller (downloadAttachment,saveAttachmentTo,saveAttachmentAsInvoice,saveAttachmentAsContractDocument) bestimmten den IMAP-Ordner weiter hart alsemail.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+findJunkFolderPathimportiert. - 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). Keinundefinedmehr Richtung IMAP-Lib.
- Beim Spam-Feature wurden
-
📧 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 umSPAMerweitert (Migration20260727110000_email_folder_spam, Enum-Wert angehängt). IMAP-Sync ermittelt den Junk-Ordnerpfad per Special-Use-Flag\Junk+ Namensliste (findJunkFolderPath),syncAllFoldersForAccountsynct ihn zusätzlich alsdbFolder=SPAM.getCachedEmails+getFolderCountsForAccountum 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).
-
📱 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) + Migration20260727100000_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.
-
🐞 Lieferbestätigung: eingegebenes Datum ändert Vertragsbeginn nicht
- Beim Upload einer Lieferbestätigung mit Datum wurde
startDatenur 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.
- Beim Upload einer Lieferbestätigung mit Datum wurde
-
🎂 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), gleichebirthdayApi.getUpcoming(7, 30)- Query + identische Karten-Darstellung (Heute/vergangen/kommend, Link auf die Kundenakte). Kein Backend-Change nötig.
- Die Geburtstags-Sektion gab es bisher nur im Vertrags-Cockpit.
Jetzt 1:1 auch auf dem Dashboard (nur Mitarbeiter/Admin,
-
🔴 Pentest R121 – Audit-Verify: Fehlalarm „manipuliert" bei leerem resourceId
POST /api/audit-logs/verifymeldete ~73% der Einträge als manipuliert. Kein echtes Tampering, sondern ein Bug ingenerateHash:resourceIdwar beim Schreiben oftundefined(middleware-Einträge ohne Route-ID).JSON.stringifyLÄSST einen undefined-Wert weg → Hash ohneresourceId-Key. In der DB landet der Wert aber alsNULL;verifyIntegrity/rehashAlllasen ihn alsnullzurück undJSON.stringify({resourceId:null})schrieb ihn REIN → anderer Hash → Fehlalarm für JEDE leere-resourceId-Zeile. Die vom Pentester gefundenedurationMs-Korrelation war ein Proxy: middleware-Einträge (durationMs gesetzt) haben oft kein resourceId.- Fix:
generateHashlässt nullishresourceIdweg – reproduziert exakt das historische Schreibverhalten. Kein Caller übergibt jenullexplizit (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: einmaligPOST /api/audit-logs/rehash(Admin). Nicht Teil dieses Fixes.
-
🔧 Pentest R120 – Audit-Log
/:idmit nicht-numerischer ID → 500 statt 400- Pentester stiess beim Suchen eines
verify-integrity-Endpoints auf einen 500er. Ursache:GET /api/audit-logs/verifymatchtGET /:id(das echte Verify istPOST /verify),parseInt("verify")= NaN → PrismafindUnique({ where:{ id: NaN }})wirft → 500. Gleiche „400 statt 500"-Klasse wie R64.1/R104.1. - Fix:
Number.isNaN-Guard ingetAuditLogById,getAuditLogsByCustomerundupdateRetentionPolicy→ sauberer 400.
- Pentester stiess beim Suchen eines
-
🔴 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). Dertree=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). 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 inproviderName/tariffNameeines Staging-Vertrags. Nicht exploitierbar – keine der 9dangerouslySetInnerHTML-Stellen rendert Vertrags-Provider/Tarif-Namen, React escaped sie als Text. Neue Writes werden zusätzlich persanitizeContractBody(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.emitAccessDeniedschreibt jetzt zusätzlich einen tamper-evidenten AuditLog-Eintrag (action: READ,resourceType: 'AccessDenied', Sensitivity HIGH) für allecanAccess*-403. Meine ursprüngliche Formulierung „landet im Audit" war die falsche Tabelle – jetzt stimmt sie.
-
👁 Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"
- Der Vertragsbaum beim Kunden (
CustomerDetail→ Tab Verträge) blendeteDEACTIVATED-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- ParamincludeDeactivated=true, Toggle-Button im Kunden-Vertragstab (Eye/EyeOff).showDeactivatedist Teil des Query-Keys → frischer Fetch beim Umschalten. Deaktivierte Verträge tragen ihr grauesDEACTIVATED-Badge, bleiben also klar erkennbar.
- Der Vertragsbaum beim Kunden (
-
🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht
- Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen
des Folgevertrags an der
@unique-Regel aufContract.previousContractId– wenn A → B → C stand und B gelöscht wurde, hielt B im UPDATE-Moment noch selbstpreviousContractId=A, C sollte auch auf A → Duplicate- Entry, das Update brach ab. (2) Der Frontend-deleteMutationinContractDetailinvalidierte die Contract-Listen-Query nicht, navigierte nur nach/contractsund 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.
- Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen
des Folgevertrags an der
-
📝 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 dediziertesUPDATE-Event unterresourceType: 'ContractPassword'— landet damit in derselben CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads. - Motivation: das Setzen des Flags räumt server-seitig
portalUsername/portalPasswordEncrypted/stressfreiEmailIdaufNULL. Diese Löschung sensitiver Daten ist jetzt unabhängig vom generischen Diff nachweisbar. Rücknahme wird ebenfalls geloggt. detailsenthält alte + neue Flag-Werte plus Bool-Marker, welche Anmeldedaten vor dem Opt-out belegt waren (keine Klartext-Leckage).
- Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des
-
🔕 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) + Migration20260717100000_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.
- Dritter Radio-Button in der
-
🔧 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.errormit Stacktrace und einem Info-Toast, falls die SIM in der DB gar keine PIN/PUK hinterlegt hat. Kein Verhaltens-Change am Endpoint – nur Diagnose.
- Der stumme
-
🔗 Vertrag-Zugangsdaten: Externtab-Link zu Kunden-Adressen
- Kleines Externtab-Icon neben dem Label „Portal Benutzername", öffnet
/customers/{id}?tab=stressfreiin 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 aufcanAccessCustomersteht).
- Kleines Externtab-Icon neben dem Label „Portal Benutzername", öffnet
-
🔧 Pentest R111 – contract-categories: falscher Permission-String
- Der Pentester meldete 403 auf
POST/PUT/DELETE /api/contract-categories/*für Admin trotz vorhandenercontract-categories:*-Permissions. Vermutete Regression aus R110, war aber schon seit dem ersten Commit so – die Route verlangtedeveloper:access, während der Permission-Seed den Adminscontract-categories:*gibt. - Fix: Route auf
contract-categories:create/update/deleteumgestellt, konsistent mit den sechs Peer-Katalog-Endpunkten (platforms/tariffs/cancellation-periods/…). Kommentar aktualisiert, damit die historische Design-Restriktion nicht wieder eingebaut wird.
- Der Pentester meldete 403 auf
-
🔒 Pentest R110 – Mass-Assignment-Whitelist auf 7 Update-Endpunkten
- MEDIUM-Finding:
PUT /api/stressfrei-emails/:idund 6 weitere Update- Endpunkte (platform,tariff,contractCategory,cancellationPeriod,contractDuration,email-providers) reichtenreq.bodyungefiltert an Prisma – gleiche Bug-Klasse wie M1 (Settings Mass Assignment). Nachgewiesen war es viaprovisionError-Feld ausserhalb des TS-Types. - Fix: sieben Whitelists +
pickXxxUpdate()-Helper insanitize.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.
- MEDIUM-Finding:
-
🐞 Kündigungsdatum: Cursor sprang beim Tippen aus dem Feld
<input type=date>feuerteonChangebei jedem Tastendruck; sobald z.B.18.08.0002ein 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, feuertonSaveerst 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.
-
🐞 IMAP-Fehler: „Command failed" ohne Kontext
imapflowwirft bei jedem IMAP-NO/BAD-Response nurnew Error('Command failed')und legt Details in.responseText/.responseStatusab. 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 inimapService.ts. Extrahiert responseText/responseStatus, erkennt Auth-Fehler, UID-Stale, TLS-/Netzwerk-Fehler und gibt jeweils eine klare Meldung mit Handlungsanweisung zurück. Angewendet auffetchAttachmentInner,downloadAttachment-Controller undsyncEmailsForAccount.
-
🆕 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.
- Nach dem Anlegen des
-
🔒 Pentest R104.1 – Salutation-Endpunkte: 404 statt 500
- INFO-Finding:
GET/PUT /api/customers/:id/salutation-preferencewarfen bei nicht-existierendem Customer 500 statt 404. - Ursache:
canAccessCustomerprüft für Staff-User nur Portal-Flag, kein Existenz-Check. Service warfnew Error(...), Controller- Catch mappte generisch auf 500. - Fix: neuer Helper
assertCustomerExists()im Service wirftApiError(404, ...). Die drei Controller-Catches respektieren jetztApiError.statusCode. Auchset/clearprüfen Existenz (sonst käme ein Prisma-P2003 als 500 durch).
- INFO-Finding:
-
🔒 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:
contractNumberzuCONTRACT_IDENTIFIER_FIELDShinzugefügt – bestehende R86-Whitelist (^[A-Za-z0-9_\-/. ]{0,100}$) greift automatisch, R87-Fix (Raw-Input vor stripHtml) ist auch drin. LegitimeGAS-MPVJ4P10FE8bleibt OK, alle Angriffs-Payloads → 400. - Doku in
SECURITY-HARDENING.md § Runde 102.
- INFO-Finding:
-
🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log
- Pentester R101.1 (INFO/funktional) berichtet:
?disposition=inlinebewirkt nichts, Browser zeigt Download-Dialog. Die Logik imfileDownload.controllerist eigentlich korrekt – sauberer Magic- Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest gegen echte Vertrags-PDFsapplication/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, wenninlineangefragt 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.
- Pentester R101.1 (INFO/funktional) berichtet:
-
🔒 Pentest R97 – Attachment-Validierung im Send-Handler
- R97.1 (LOW): malformed
content(null, fehlend,true,"") erzeugte 200/500 mit rohemBuffer.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 ControllersendEmailFromAccountläuft vor demsendEmail-Aufruf:attachmentsmuss Array oder undefined sein- max 25 Anhänge
- jeder:
filenamenon-empty String,contentnon-empty Base64- String (Regex), optionalcontentTypeString - 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.
- R97.1 (LOW): malformed
-
🆕 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 viafileUrl(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
documentPathvorhanden ist.
- Vertragsdokumente: listet alle am Vertrag gespeicherten Dokumente
gruppiert nach
- Beim Bestätigen werden die Text-Blöcke an das Body-Ende gehängt
(mit
\n\n-Separator), Anhänge perserverFileToAttachmentauscomposeAttachmentHelpers.tsgezogen. 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.
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
"Datei anhängen":
-
🔒 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()insanitize.tsmit 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 insanitizeContractBody. - 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.
- R95.1 (MEDIUM):
-
🔒 Pentest R93 – Leerer String != fehlender Param
- R93.1 (INFO):
?accountId=(explizit-leer) wurde wie?accountIdweggelassen behandelt → 200 statt 400 auf optionalen Endpunkten. Pentester-Spec: leerer String ist KEINE gültige Zahl. - Fix im
parsePositiveIntQuery()-Helper: striktere Absent-Logik (v === undefinedist absent;'',' ', alles andere muss parsen). Required + optional Modes unverändert. - Float-Grenzfall (
accountId=5.5→ 5 viaparseInt) bleibt als by-design akzeptiert (Pentester-Bestätigung, kein Security-Impact).
- R93.1 (INFO):
-
🔒 Pentest R92 – Strict-400 für accountId auf Vertrags-Endpunkten
- R91-Fix war silent-undefined bei invaliden Werten:
accountId=abcaufGET /contracts/:id/emailsergab "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
- default (optional): fehlend/leer →
- Verteilung:
- Contract-Emails, Contract-Folder-Counts:
{ required: true } - Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox- View ist legitim), invalid → 400
- Contract-Emails, Contract-Folder-Counts:
- Frontend hat schon ein
enabled: !!selectedAccountId-Guard auf den Vertrags-Queries – kein UX-Bruch.
- R91-Fix war silent-undefined bei invaliden Werten:
-
🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param
- R91.1 (LOW):
accountId=abc→parseInt('abc')=NaN→ der Ternary im Controller gabNaNan den Service,if (NaN)ist falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit ungültigemaccountIdsah alle Mailbox-Mails für seinen Vertrag statt nur die aus dem gewählten Postfach (kein Cross-Customer- Leak —canAccessContractgreift weiter). - Fix: zentraler
parsePositiveIntParam()imcachedEmail.controller.ts, der nur positive Ganzzahlen aus dem Query-String akzeptiert und alles andere zuundefinedmacht. Eingesetzt in allen 5 Endpunkten, dieaccountId/contractIdaus 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.
- R91.1 (LOW):
-
🐞 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:
getEmailsForContractcontroller akzeptiert jetztaccountId- Query-Param und reicht ihn alsstressfreiEmailIdangetCachedEmailsweiter (der hat den Filter eh schon implementiert, nur niemand hat ihn aufgerufen).getTrashEmails(controller + service) akzeptiertaccountIdundcontractIdals optionale Filter. Default-Verhalten unverändert, wenn keiner gesetzt ist.getFolderCountsForContractakzeptiert optionalstressfreiEmailId, bekommt zusätzlichtrash+trashUnreadins Result – sonst läge der Trash-Badge im Vertrag wieder account-global, während die Liste contract-scoped ist.
- Frontend:
cachedEmailApi.getForContract/getTrash/getContractFolderCountsnehmen den Filter entgegen.ContractEmailsSectionreichtselectedAccountIdin 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.EmailClientTabreichtselectedAccountIdin die Trash-Query durch (Inbox/Sent waren schon korrekt).
-
🔒 Pentest R89 – Provider-Adressfelder härten
- R89.1 (MEDIUM):
sanitizeNotes(…, 500)macht silentslice(0, 500)statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit 200 OK gespeichert. - R89.2 (LOW):
stripHtmllief vor dem Length-Check –<script>…</script>reduzierte auf leeren String →nullin der DB → vorheriger Wert silent überschrieben (R87.1-Pattern auf Adress-Feldern). - Fix: eigener
validateProviderAddress()insanitize.ts. Raw-Input, max 500 →ApiError(400), Blacklist<,>, Tab, alle Control- Chars außer\n. CRLF → LF normalisiert vor Length-Check. Eingehängt instripProviderStrings. - 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.
- R89.1 (MEDIUM):
-
🆕 Anbieter: Kontakt + Kündigung als Stammdaten
- Sieben neue optionale Felder am
Provider-Modell:contactEmail,contactPhone,contactFax,contactAddress,cancellationEmail,cancellationFax,cancellationAddress. Postadressen alsTEXT(mehrzeilig), RestVARCHAR(191). Migration20260621100000_provider_contact_and_cancellationmitIF 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}>mitmaxLength={500}. - Backend-Validierung: contactEmail/cancellationEmail laufen durch
isValidEmail(Header-Injection-Schutz für Mail-Templates), contactPhone/contactFax/cancellationFax durchsanitizePhoneField(kein CRLF/Control-Char), Postadressen durchsanitizeNotesmit 500-Cap. - Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen Felder beim Backup/Restore verloren.
- Sieben neue optionale Felder am
-
🔒 Pentest R87 – Whitelist vor Sanitizer (silent-mutation-Schutz)
- R87.1 (LOW):
stripHtmllief im R86-Fix VOR der Whitelist. Tags wurden still weggestrippt → 200 OK mit mutierten Werten,<script>…</script>reduzierte auf leeren String →nullin der DB → vorheriger Wert ohne Fehlermeldung überschrieben. - Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
umgedreht –
validateContractIdentifierlä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 inSECURITY-HARDENING.md § Runde 87.
- R87.1 (LOW):
-
🔒 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=…) überlebtestripHtml– kein Risiko in der React- UI, aber relevant für PDF/Mail/CSV-Export. - Fix: zentraler
validateContractIdentifier()insanitize.tsmit Max-100 und Whitelist^[A-Za-z0-9_\-/. ]{0,100}$. Bewusst literales Space statt\s, damit kein CRLF/Tab passiert (Header- Injection). WirftApiError(400, …)mit klarer Meldung. - Eingehängt in
sanitizeContractBody→ läuft automatisch für alle fünf Identifier-Felder bei Create/Update. ContractForm bekommtmaxLength={100}als UX-Schicht. Doku indocs/SECURITY-HARDENING.md§ Runde 86.
- R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
Auftragsnummer warfen 500 (DB-Overflow
-
🆕 Vertrag: Auftragsnummer bei Vertriebsplattform
- Neues optionales Feld
Contract.orderNumberAtSalesPlatform(VARCHAR(191) NULL), Migration20260619100000_contract_order_number_at_sales_platformmitIF 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.
- Neues optionales Feld
-
🐞 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" → schreibtczurück inadditionalForwardingEmails→ 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 Defaulttrue.setAdditionalForwardsruft mitfalseauf → entfernte Adressen verschwinden jetzt sauber bei Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin alte Bestands-Members).
- Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
Plesk-Sync läuft → Auto-Import (
-
🐞 Plesk-Sync:
-forwarding-addresses set:existiert gar nicht- Folge-Bug nach
a83358b/24e152b: Sync verändert Plesk weiterhin nicht.plesk bin mail --helpzeigt:-forwarding-addressesakzeptiert ausschließlichadd:unddel:– unserset:wurde von Plesk silent verworfen. Außerdem gibt es keine separate-mailgroup-Option; was Plesk im--infoalsMailgroup: truezeigt, ist genau das, was-forwarding truein der CLI setzt (doppelt benannt). Mein vorheriges-mailgroup falselief auf den Phantom-Parameter und triggerteUnrecognized option. updateForwardTargetsbaut jetzt den Diff: aktuelle Mailgroup- Members (ausemailExists) 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.dehinzugefügt.
- Folge-Bug nach
-
🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten
- 83.1 MEDIUM: Auto-Import in
syncForwardingForEmailumgingassertValidForwardingEmail. Plesk-Member wieattacker@plesk.internaloderevil@x.localwären ohne TLD-Block-Check (71.1) in unsere DB gewandert. Fix: jeder importierte Member läuft durchassertValidForwardingEmail; ungültige werden silent gedroppt und aufconsole.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.debugumgestellt (stattconsole.logauf Default-Level). - Smoke-Test mit gemischter Plesk-Liste:
karibik61@web.de(legit) importiert,attacker@plesk.internal+evil@x.localper 83.1 abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2 abgelehnt, Customer-Stamm-Mail + Default deduped.
- 83.1 MEDIUM: Auto-Import in
-
🐞 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 → unsereset:-Befehle landeten in einer ungenutzten Tabelle. Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt wurden (Forwarding-Modus von Anfang an). EmailExistsResultummailgroupActive+mailgroupMembers+forwardingActive+forwardingTargetserweitert.pleskProvider.emailExistsparst alle vier Felder aus dem--info-stdout (Mailgroup: true|false,Group member(s): ...,Forward request: ...).pleskProvider.updateForwardTargetssetzt 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 unsereadditionalForwardingEmails-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.
- 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,
-
🔒 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ätzlichmeta.emailaus der DB und vergleichtcanonicalEmailKey(eintrag)gegencanonicalEmailKey(meta.email). Bei Treffer hartesApiError(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 identischemcanonicalize-Helper (Plus-Tag strippen, lowercase). Neuer PropselfEmail, 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".
- Bug: User konnte die Stressfrei-Adresse selbst (z.B.
-
🔒 Pentest 77.3 (LOW):
requireIdParamließ Float-IDs durchNumber.isInteger(parseInt('4.5'))isttrue, weilparseIntden Nachkomma-Teil silent abschneidet. Damit traf/.../4.5/...auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für4.0und Exp-Notation4e1.- Fix: vorm Parsen Regex
/^\d+$/auf die rohereq.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,9999999durchgewunken.
-
🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
anlegen (siehe Screenshot mit 2×
max.mustermann@...).createEmailhatte keinen Duplikatscheck,updateEmailebenfalls nicht. - Service: Vor
prisma.createjetztfindFirstauf(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, mitNOT id-Exclude.- Controller:
catch-Blöcke honorieren jetzt denApiError.statusCode(vorher pauschal 400) → 409 kommt sauber durch. - Frontend:
updateMutationbekam einonError, damit der 409 nicht nur ins Leere lief.
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
anlegen (siehe Screenshot mit 2×
-
🔒 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 inassertValidForwardingEmailjetzt hart abgelehnt. Verhindert Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups. - 71.2 LOW: Neuer Helper
canonicalEmailKeynormalisiert Mail- Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen, lowercase).billing+pentest@x.deundbilling@x.dewerden als dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail und im sync-Pfad gegenconfig.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 instressfreiEmail.controller.tsumgestellt (auch über das gemeldete Finding hinaus). - 71.4 INFO:
setAdditionalForwardsrollt den DB-Stand bei Provider-Sync-Fehler zurück, damit DB und Plesk nicht auseinanderlaufen. VorherigeradditionalForwardingEmails-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).
- 71.1 MEDIUM: Reservierte/private TLDs (
-
🆕 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
createEmailmit der vollen Liste. - Sub-Modal generalisiert:
value/onChange-Pattern (controlled). Mitemail-Prop → API-Persist pro Änderung (Edit). Ohneemail→ lokaler State (Create). Counter-Badge am Button zeigt die Anzahl Adressen.
- 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
-
🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele
- Neues Feld
StressfreiEmail.additionalForwardingEmails(Text/ JSON-Array), Migration20260608100000_stressfrei_email_additional_forwardsmitIF NOT EXISTS. syncForwardingForEmailzieht die zusätzlichen Adressen mit in die Plesk-set:-Liste ein (case-insensitive Dedup gegencustomer.emailundconfig.defaultForwardEmail).- Neuer Endpoint
PUT /api/stressfrei-emails/:id/additional-forwardsmit 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.
- Neues Feld
-
🐞 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 durchformData.X !== prop.X. Jeder Tastendruck setzte den State zurück. - Fix in allen vier Modals (MeterModal, BankCardModal,
IdentityDocumentModal, MeterReadingModal): nach
useEffectmit[<entity>?.id]-Dependency umgezogen.
- Vier identische Vorkommen desselben Anti-Patterns wie beim
AddressModal-Fix von 2026-06-03:
-
🐞 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).
-
🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"
Tabs-Komponente um optionalen ProptabHrefBuilder(tabId)erweitert. Wenn gesetzt, erscheint neben jedem Tab-Label ein kleinesExternalLink-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.
-
🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform
- Zwei neue optionale Felder
Contract.customerNumberAtSalesPlatform+contractNumberAtSalesPlatform, Migration20260603150000_contract_sales_platform_numbersmitIF 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
contractNumberAtProviderim Renewal-Copy und Audit-Label-Mapping ist gleich mit drin – wurde bisher nicht in VVL-Folgeverträge kopiert.
- Zwei neue optionale Felder
-
🆕 Email-Links öffnen im neuen Tab
- In
EmailDetailnach der DOMPurify-Sanitize jedes<a>-Element auftarget="_blank"+rel="noopener noreferrer"gesetzt. Letzteres verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen in einemuseMemo, das nur bei Wechsel der Email neu rechnet.
- In
-
🐞 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 /JavaScriptweiterhin blockiert, clean PDF weiterhin OK.
-
🐞 AddressModal: Straße-Feld ließ sich nicht editieren
setFormDatawurde unbedingt im Render-Body aufgerufen, wennformData.street !== address.street. Jeder Tastendruck löste neu aus → Reset auf DB-Wert → Cursor sprang zurück → keine Eingabe möglich.- Fix: in
useEffectmit[address?.id]-Dependency umgezogen. Re-Init nur beim Wechsel/Open, nicht bei jedem Render.
-
🐞 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 inupload.routes.ts,gdpr.routes.ts,contract.routes.tsauf 25 MB hochgezogen.pdfTemplate.routes.tswar eh schon bei 20 MB. - Frontend:
FileUpload-Hinweis „max. 10 MB" → „max. 25 MB".
- Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit
(auch mit Original-Bytes-Optimierung aus
-
🆕 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), Migration20260603100000_sim_card_esimmitIF NOT EXISTS. - Backend: alle vier SimCard-Schreibpfade in
contract.service.ts(Create + Update + Follow-Up + Renewal) plus FE-Type-Definition. - UI: dritte Checkbox in
ContractFormzwischen Hauptkarte und Multisim. ContractDetail zeigt blaueneSIM-Badge neben Hauptkarte.
-
🆕 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)statt1.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.
-
🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type
- Globaler Error-Handler in
index.ts:461matcht/sind erlaubt|nicht erlaubt/iund 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 erlaubtmacht 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".
- Globaler Error-Handler in
-
🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter
contract.routes.tsVertragsdokumente: Multer-fileFilter blockteimage/gifundimage/webp, obwohlvalidateUploadedFilebeide Typen zulässt. Folge: GIF mit korrektem MIMEimage/gif→ 415, GIF mit gespooftem MIMEimage/jpeg→ akzeptiert (vom Magic-Byte als.giferkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte Guard), aber inkonsistent.- Fix: Whitelist um
image/gif+image/webpergänzt → konsistent zum zentralenvalidateUploadedFileund zuupload.routes.ts.
-
🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert
contract.routes.tsVertragsdokumente-Upload hatte bisher nur den PDF-Inhalts-Scan (scanUploadedPdfIfPresentaus 68.1). JPG/PNG- Uploads waren ungeprüft – kompensiert durch Download-Layer (fileDownload.controller.tsliefert nur bei Magic-Byte-Match inline aus, sonst attachment). Pentester selbst: "ohne Exploit- Pfad", aber inkonsistent zuupload.routes.ts.- Refactor:
detectType+validateUploadedFileausupload.routes.tsin neue Middlewaremiddleware/uploadFileTypeValidator.tsausgelagert (Single Source of Truth). Beide Routes nutzen jetzt denselben Helper. - contract.routes.ts:
validateUploadedFileersetzt das schlankerescanUploadedPdfIfPresent– jetzt greift Magic-Byte + canonical Rename + PDF-Scan für Vertragsdokumente analog zu allen anderen Upload-Pfaden. - pdfUploadSafety.ts:
scanUploadedPdfIfPresententfernt (tot, da nur in contract.routes verwendet wurde).requireSafeUploadedPdfbleibt für gdpr.routes Vollmacht + pdfTemplate.routes.
-
🔒 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)inutils/sanitize.ts: String-Scan auf die fünf Action-Pattern (case-sensitive nach PDF 32000-1:2008 §7.3.5). WirftApiError(415, ...)bei Treffer. - Neue Middleware
pdfUploadSafety.tsmit 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.tsVollmacht-UploadpdfTemplate.routes.tsTemplate-Uploadcontract.routes.tsVertragsdokumentecachedEmail.controller.tsEmail-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:
/JSXFormund/JavaScriptFooterwerden NICHT als JavaScript-Action erkannt (word-boundary\bgreift). - 68.2 Modal-Limit:
JpgToPdfModalhatte kein Bild-/Größen-Limit. JetztMAX_IMAGES = 50+MAX_IMAGE_BYTES = 25 MBpro Bild. UX-Schutz, kein Security-Bug (Self-DoS only).
- 68.1 PDF-Active-Content-Filter: Magic-Byte-Check prüfte bisher
nur
-
🆕 JPGs → PDF: Button überall bei PDF-Upload
- Neue Komponente
JpgToPdfModal(lokal im Browser viajspdf, keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per Klick, Drag&Drop oderStrg+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, wennacceptPDF 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.
- Neue Komponente
-
🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"
customerEmailLabelexistierte im Backend (Schema + Update-Logik + Public-Endpoint), war im UI aber nicht erreichbar – das Label kam ausschließlich ausderiveLabelFromDomain. 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, diecustomerEmailLabelnutzen (max 60 Zeichen).
-
🆕 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), Migration20260601100000_sim_card_usermitIF 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).
- Bei Firmen- und Familienverträgen weicht der
Vertragsinhaber (Firma/Eltern) vom tatsächlichen Nutzer
(Mitarbeiter/Kind) ab. Neuer optionaler
-
🆕 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+contractNumberAtProviderversteckt, obwohl sie gepflegt sein können. Fix: Sichtbarkeitsbedingung um die beiden Felder erweitert.
- Die „Anbieter & Tarif"-Card im ContractDetailModal war
nur sichtbar, wenn Provider oder Tarif gesetzt waren –
Bei Entwürfen ohne Anbieter wurden so auch
-
🆕 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.
- „Postfach öffnen" + „Stressfrei wechseln Adressen" waren
nur im Normal-Zweig sichtbar. Jetzt in einer gemeinsamen
-
🆕 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
ReactNodeals label (statt nur string), rückwärtskompatibel. - In ContractDetail (Strom/Gas Card-Header) zusätzlich permanenter „Zähler verwalten"-Link in neuem Tab.
-
🆕 Vertragshistorie: Vertragsnummern als Link
- Erkennt Vertragsnummern (
PREFIX-RANDOM) intitleunddescriptionvia Regex, löst sie gegenpreviousContract+followUpContractdes aktuellen Vertrags auf und rendert sie als Link in neuem Tab. Nicht aufgelöste Nummern bleiben als Text.
- Erkennt Vertragsnummern (
-
🆕 UI-Bug-Fix: „Wurde sondergekündigt?"-Checkbox-Label
- Label-Klasse war
flex→ Block-Layout über vollecol-span-2-Breite. Klick rechts neben dem Text triggerte ungewollt die Checkbox. Fix:inline-flex.
- Label-Klasse war
-
🆕 „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.
-
🆕 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=falsegesetzt – ein-klick-fähiger Zählerwechsel. - Backend:
createMetermitsuccessorOfundaddSuccessorMeterakzeptierendeactivatePredecessor(Default true).
- Beide Folgezähler-Forms (Kundenakte MeterModal +
Vertragsansicht SuccessorMeterForm) bekommen eine
Checkbox, die standardmäßig angehakt ist. Beim Speichern
wird der Vorgänger automatisch auf
-
🆕 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
- Pro Zähler ein „Verträge (N)"-Aufklapp, listet alle
Verträge auf, die diesen Zähler nutzen – als Hauptzähler
(
-
🆕 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.meterIdbei Single-Meter-Verträgen automatisch als ContractMeter (position 0,removedAt= Wechseldatum) backfillt, damit der alte Zähler in der Historie bleibt.
- 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
-
🆕 Multi-Meter-Verbrauch auf Vertragslaufzeit clampen
- Bei Verträgen, die Vorgänger einer Folgevertrags-Kette
sind, hängen über
ContractMeterauch Folgezähler dran, die nach Vertragsende installiert wurden. Die Berechnung nahmcm.installedAt..cm.removedAt1:1 ohne Clamp gegencontract.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.
- Bei Verträgen, die Vorgänger einer Folgevertrags-Kette
sind, hängen über
-
🆕 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).
-
🛡️ Pentest 43.5 (INFO) + 43.6 (MEDIUM): History-XSS + blocked:-Marker
- 43.6:
ContractHistoryEntry.title+.descriptionwaren auf beiden Pfaden ungestrippt – Admin konnte HTML/Script- Tags einschreiben, Portal-User las sie roh zurück. Fix:stripHtml()auf Create + Update (Write-Pfad) undsanitizeEntry()im List + Get (Read-Pfad). - 43.5:
stripHtmlersetztjavascript:→blocked:– sinnvoll bei URL-Feldern, hässlich in Tarif-/Preis-Namen. NeuerstripForDisplay-Wrapper entfernt den Marker zusätzlich in den Display-Feldern.
- 43.6:
-
🛡️ Pentest 42.5 (MEDIUM): priceFirst12Months XSS
- Drei Preisfelder sind im Schema
String?(freitextliche Tarifangaben).sanitizeContractstrippte sie auf dem Read-Pfad nicht – Alt-Daten mit XSS-Payloads kamen 1:1 raus. Fix:priceFirst12Months,priceFrom13Months,priceAfter24MonthsinCONTRACT_DISPLAY_STRING_FIELDSaufgenommen.
- Drei Preisfelder sind im Schema
-
🛡️ Anzeige-Defense: HTML in display-relevanten Strings strippen
sanitizeContractundsanitizeCustomerstrippen jetzt zusätzlich HTML in den definierten Display-Feldern (providerName,tariffName,customerNumberAtProvider,firstName,lastName,companyName, etc.). Wirkt auch auf nestedpreviousContract+energyDetails. Damit sehen Pentester-Payloads (<script>,<img onerror>) in der Vertragsliste nicht mehr als hässlicher Klartext aus, sondern verschwinden komplett.
-
🆕 Vorvertrag-Verbrauch als Schätzwert im Folgevertrag
- ContractForm (Strom/Gas): Wenn ein
previousContractIdgesetzt ist, wird der Vorvertrag samt Readings nachgeladen und der Verbrauch clientseitig übercalculateMultiMeterConsumption/calculateConsumptionberechnet. Unter dem Jahresverbrauch-Feld erscheintVorvertrag: 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
annualConsumptionleer ist und ein berechenbarer Vorvertrag existiert, wird die Jahresverbrauch-Zelle stattdessen mit~1.698 kWhin 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.
- ContractForm (Strom/Gas): Wenn ein
-
🆕 Endstand alter Zähler fließt in Verbrauchsberechnung ein
- Bisher wurde der Wert „Letzter Stand alter Zähler" zwar als
ContractMeter.finalReadinggespeichert, aber nirgends gelesen – weder in der UI noch incalculateMultiMeterConsumption. - Neuer Helper
recordPredecessorFinalReading(meterId, switchAt, value)in customer.service.ts: legt am Wechseldatum einen regulärenMeterReading-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:
addSuccessorMeterim contract.controller (Vertragsansicht → „Folgezähler hinzufügen") undcreateMetermitsuccessorOfim 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
calculateMultiMeterConsumptionautomatisch in den Verbrauch (Zeitraum bisremovedAtist inklusive). - UI-Hinweise im Folgezähler-Form (Vertragsansicht + MeterModal) erklären den neuen Effekt.
- Bisher wurde der Wert „Letzter Stand alter Zähler" zwar als
-
🆕 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 bisherigeenergyDetails.meterIdautomatisch als ContractMeter (position 0,removedAt= Wechseldatum) backfillt, damit der alte Zähler nicht aus der Vertragshistorie verschwindet.
- Bisher nur sichtbar im Multi-Meter-Zweig (
-
🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)
- Backend: Neues Feld
Meter.predecessorMeterId(Self-Relation,ON DELETE SET NULL). Migration20260530140000_meter_predecessormitIF NOT EXISTS.createMeterakzeptiert optionalsuccessorOf: { 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 zuaddSuccessorMeterpropagiert (vorhandener ContractMeter wirdremovedAt+finalReadinggesetzt; neuer ContractMeter wird mit nächster Position +installedAtangelegt;energyDetails.meterIdauf 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".
- Backend: Neues Feld
-
🆕 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.
-
🆕 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter
- Backend: Neues Feld
Meter.addressId(optional FK aufAddress,ON DELETE SET NULL). Migration20260530100000_meter_addressmitIF NOT EXISTS. Service erzwingt beim Create: Lieferadresse muss vorhanden, zum Kunden gehören und TypDELIVERY_RESIDENCEhaben. - 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".
- Backend: Neues Feld
-
🆕 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(Migration20260519100000_backup_logmitIF NOT EXISTS). Limit 1 MB profullLog, 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 riefdeleteDirectory(UPLOADS_DIR)mit dem finalenrmdirSync, aber/app/uploadsist ein Bind-Mount, den Linux nicht aushängen lässt. Fix: neuer HelperemptyDirectory()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.
- 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
-
🛡️ 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.1etc. → 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 → stricttrue/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→ 400http://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)
- Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend.
API-Endpoint akzeptierte sonst
-
🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert
- Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
sanitizeContractBody-Funktion lief auch überportalPassword. 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 durchstripHtmlgeschickt. PW wird sowiesoencrypt()-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 aufEvilProvidergestrippt.portalUsername: u<test>→ weiter aufugestrippt.
- Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
-
🛡️ Pentest 2026-05-24 Pen-31-Befunde (2× MEDIUM)
- 31.1 Stored XSS in Vertragsfeldern:
providerName,tariffName,priceFirst12Months,priceFrom13Months,priceAfter24Monthsnahmen rohe HTML/Script-Payloads an und lieferten sie 1:1 zurück. Fix: rekursiversanitizeContractBody()(Walk-and-Strip) im contract.controller wird aufreq.bodyvon POST + PUT angewandt. Nutzt das bestehendestripHtml()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 mitrequireCustomerAccess): das Middleware short-circuitete aufcustomers: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 inmiddleware/auth.ts:requireCustomerAccess: erstisCustomerPortal-Check (eigene + vertretene IDs), DANN erst Perm-Check für Mitarbeiter. Damit sind alle 6 Routes mit einem Middleware-Patch dicht. Defense-in-Depth: instressfreiEmail.controller.getEmailsByCustomerzusätzlichcanAccessCustomer-Call analog zum POST-Handler. - Infos (keine Code-Änderung):
type:"STROM"ist deprecated – richtige Enum istELECTRICITY.- 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.).
- Portal-User 1 vs Customer 3: alle 6 Routes 403
(
- 31.1 Stored XSS in Vertragsfeldern:
-
🆕 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 viacustomerApi.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
unitPriceund (bei DUAL-Zählern)unitPriceNt.
- 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
-
🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)
- Bisher gab es ein einzelnes
bonus-Feld aufEnergyContractDetails. Jetzt zwei FelderinstantBonus(Sofort) undnewCustomerBonus(Neukunden), die zusammen den Gesamtbonus ergeben. - Migration
20260524100000_split_energy_bonus:ADD COLUMN IF NOT EXISTS instantBonus,... newCustomerBonus,UPDATEkopiert bestehendesbonus→instantBonus(Annahme: Bestandsverträge hatten primär Sofort-Bonus), dannDROP 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;CostCalculationliefertinstantBonus,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.
- Bisher gab es ein einzelnes
-
🛡️ Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)
- 30.13 MIME-Extension-XSS (MEDIUM):
GET /api/files/downloadlieferte hochgeladene Dateien viares.sendFileaus. Da multer nur den client-gemeldeten MIME prüft, konnte eine alsapplication/pdfdeklarierte.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>+ bestehendesX-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=trueerweitert 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
- Upload + Download: Header zeigt
- 30.13 MIME-Extension-XSS (MEDIUM):
-
🛡️ Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)
- 28.1 Restarbeit:
DANGEROUS_URI_SCHEMESjetzt 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 auchjava%2573cript:ab, das zuerst zujava%73cript:und dann zujavascript:wird). - 29.3 Zero-Width-Joiner:
javascript:mit U+200B/200C/200D etc. wird durchZERO_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/bashjetzt 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 wietest@x.de\nBcc:attacker@…) sowie Format- Müll wienotanemailab. Verdrahtet increateCustomer/updateCustomer/updatePortalSettings/createUser/updateUser. Liefert 400. - 29.5 GET /api/providers/email 500 → 404:
parseInt("email")warNaN, Prisma-Query crashte. Controller validiert jetztNumber.isFinite(id) && id ≥ 1und 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"→ 400email: "test@x.de\nBcc:..."→ 400GET /providers/email→ 404 (war 500)GET /providers/<valid_id>→ 200
- blob:/about:/ws:/wss:/ldap:/dict: alle →
- 28.1 Restarbeit:
-
🛡️ Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)
- 28.1 URI-Schema unvollständig:
DANGEROUS_URI_SCHEMES-Regex erweitert umfile:undftp:.ftp://evil.com/x.jsundfile:///etc/passwdin companyName werden jetzt zublocked://...bzw.blocked:///...neutralisiert. - 28.2 HTML-Entity-Decoding-Bypass:
stripHtmllief vorher direkt über den Roh-String, sodassjavascript:,<script>und<script>an der Regex vorbeischlüpften. NeuerdecodeHtmlEntities()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/pdfkam 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
publicConsentRateLimitergreift jetzt auf alle drei Sub-Routes (/:hash,/:hash/grant,/:hash/pdf). - Live-verifiziert auf dev:
ftp://evil.com/x.js→blocked://evil.com/x.jsfile:///etc/passwd→blocked:///etc/passwdjavascript: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
- 28.1 URI-Schema unvollständig:
-
🧹 Pentest 2026-05-20 LOW/INFO-Sammelfix
- 27.1 Path-Traversal-Strings in DB:
cleanupConsentsvalidiertedocumentPathzuvor nur per stripHtml, was../../../etc/passwddurchließ (kein File-Read, aber dreckige Datenbasis). NeuerisValidDocumentPath-Check akzeptiert nur/?uploads/<safe>, alles andere wird aufNULLgesetzt. - Generischer
cleanupDocumentPaths-Pass über die fünf weiteren Tabellen mitdocumentPath(BankCard, IdentityDocument, Invoice, RepresentativeAuthorization als nullable; ContractDocument NOT NULL → wird nur berichtet, manuell entscheiden). - Orphaned User: Neuer Report-Step
reportOrphanedUserswarnt 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()nutzteMath.random()(vorhersagbar). Jetztcrypto.randomInt()für Pick + Shuffle, 28 Zeichen aus 4 Klassen. PUT /users/:idmitpermissions/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 wiePUT /portalfür password./api/healthohne 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
- CustomerConsent.documentPath=
- 27.1 Path-Traversal-Strings in DB:
-
🛡️ Pentest 2026-05-20 MEDIUM+LOW: Consent + URI-Sanitization
- MEDIUM Consent-Mass-Assignment: PUT
/api/gdpr/customer/:id/consents/:typenahmsource,documentPath,versionungefiltert aus dem Body – Portal-User konntesource: "ADMIN_OVERRIDE",version: "<script>"oderdocumentPath: "../../etc/passwd"durchschmuggeln. Fix: nur nochstatusaus Body, source server-seitig hardcoded auf'portal', documentPath/version bleiben NULL (werden vom dedizierten Authorization-Upload-Endpoint server-seitig gesetzt). - Whitelist für
CustomerConsent.sourceergänzt (portal | public-link | telefon | papier | email | crm-backend).grantAuthorization(Admin) erzwingt sie ebenfalls;notesläuft jetzt durchstripHtml. - LOW javascript:-URI:
stripHtml()filtert jetzt zusätzlichjavascript:,data:,vbscript:– ersetzt durchblocked:, 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 hatsource=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.
- PUT mit
- MEDIUM Consent-Mass-Assignment: PUT
-
🚨 Pentest 2026-05-20 KRITISCH: Backup-Restore ohne Confirm-Body
POST /api/settings/backup/:name/restorestartete bei leerem Body sofort den destruktiven Restore. Im Unterschied zu/factory-resetfehlte 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
-
🛡️ XSS-Sanitization für Plain-Text-AppSettings (Pentest MEDIUM)
companyName(und weitere Plain-Text-Keys wiedefaultEmailDomain,monitoringAlertEmail, Schwellenwerte) konnten via PUT/api/settings/:keymit 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)inappSetting.service.tsstrippt HTML außer für die expliziten HTML-Editor-Keys (imprintHtml,privacyPolicyHtml,authorizationTemplateHtml,websitePrivacyPolicyHtml). Greift inupdateSetting(Einzel) undupdateSettings(Bulk). - Cleanup-Skript erweitert: bestehende AppSettings mit HTML in Plain-Text-Keys werden beim Container-Start gestrippt (idempotent).
- Live-verifiziert auf dev: `
OpenCRM <script>alert(2)</script>` via PUT → DB-Wert: `"OpenCRM"`.
imprintHtmlmit<h1><p>→ unverändert.
-
🐛 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 seedper 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.tsläuft bei jedem Start. Idempotent, fasst nur Permission- und Role-Tabellen an (keine User/Customers/Contracts), führtsyncRolePermissionsfür Admin, Developer, DSGVO, Mitarbeiter, Mitarbeiter (Nur-Lesen), Kunde aus. - Live-verifiziert:
audit:readaus DSGVO-Rolle gelöscht, Script laufen lassen → "+1 Permission an Rolle #27", wieder vollständig.
- Bestehende Installationen liefen weiter mit veraltetem
Permission-Set für die DSGVO-Rolle (audit:read u.a. fehlten),
weil
-
🐛 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.tsxwar inhasPermission('settings:update')eingewickelt. DSGVO hat aber nuraudit:*undgdpr:*Perms – keinsettings: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.
-
🛡️ 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}inmiddleware/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.
-
🚨 PUT /customers/:id/portal mit
passwordim Body → 400- Endpoint nahm
passwordsilent entgegen, ignorierte es, gab aber HTTP 200 zurück → Client glaubte fälschlich, das Passwort sei gesetzt. Fix: explizite Body-Validierung –password,portalPassword,portalPasswordHash,portalPasswordEncryptedsind verbotene Felder, HTTP 400 mit Hinweis auf den dediziertenPOST /portal/password-Endpoint.
- Endpoint nahm
-
🚨 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.examplestanden schon richtig auf 15m als Default. Alle.env-Files (Root, backend/, docker/.env.example, backend/.env.example) jetzt aufJWT_EXPIRES_IN=15mmit explizitemJWT_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.
- Neue Marker-Pattern-Liste:
- 18.4 Klartext-Portal-PW-Abruf: BEWUSST DRIN GELASSEN auf
Wunsch (Admin-UI-Komfort). Klartext bleibt für Admin via
GET /customers/:id/portal/passwordabrufbar; ist mitcustomers:update-Permission gated und mit Audit-Log (READ → PortalPassword) auditiert.
- 21.1 Access-Token 7 Tage: Bug-Quelle waren die
-
🚨 Pentest Runde 15 – KRITISCH: portalPasswordHash in PUT/POST-Response
- 20.3 KRITISCH:
PUT /customers/:idgab den vollen bcrypt-Hash ($2a$12$…) zurück, weilupdateCustomerService-Output ohne sanitize-Aufruf direkt durchreichte. GET-Endpoints waren dicht, die Update-Response nicht. 20.4 HOCH gleicher Klasse:portalPasswordResetToken,consentHash,portalPasswordMustChange,portalTokenInvalidatedAtleakten ebenfalls über PUT/POST. - Fix:
updateCustomer+createCustomerrufen jetztsanitizeCustomer/sanitizeCustomerStrictauf den Service- Output (je nachcustomers:update-Permission).updateContract+createContract+createFollowUp+createRenewalanalog mitsanitizeContract/Strict (Portal-Hint viareq.user.isCustomerPortal).portalPasswordMustChangeundportalTokenInvalidatedAtzusätzlich vonPORTAL_HIDDEN_CUSTOMER_FIELDSzuSENSITIVE_CUSTOMER_FIELDShochgezogen – damit greift der Schutz auch bei der normalensanitizeCustomer-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;portalPasswordEncryptedbleibt für Admin sichtbar (UI-Workflow) - Portal-User
GET /customers/3→ 0 Leaks auch bei portalPasswordEncrypted/notes POST /customers(create) ebenfalls dicht
- Admin
- 20.3 KRITISCH:
-
🛟 Admin-Rescue-Script (PW-Reset direkt in DB + Rate-Limit-Reset)
- Use Case: Admin sperrt sich aus (z.B.
admin@admin.comist 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 inuser.password, setzttokenInvalidatedAt = 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 viadocker 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.
- Use Case: Admin sperrt sich aus (z.B.
-
🔐 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 ZeichensetPortalPassword+changeInitialPortalPassword→ bleibt 12confirmPasswordReset: 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.
- UserList: Hinweis-Text "Mind. 25 Zeichen". Update + Passwort
gleichzeitig → Frontend macht jetzt zwei Calls (PUT + neuer
- 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 → successPOST /customers/3/portal/password "Hallo123!Test"(12) → 200POST /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.
-
🚨 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.tsläuft beim Container-Start. Strippt HTML aus Customer/User-String-Feldern; entfernt AppSettings, deren Key nicht inALLOWED_SETTING_KEYSsteht. Mehrfacher Aufruf ändert nichts. - User-Update akzeptierte
password-Feld: stillschweigend ohne dedizierten Audit-Eintrag. Jetzt:passwordausUSER_UPDATABLE_FIELDSraus (CREATE behält es weiterhin); neuer EndpointPOST /api/users/:id/passwordmit 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 globalenres.json()-Wrapper. - POST /contracts substring-Crash: defensiv –
typefehlt → 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.
- M2-Reste (XSS-Strings noch in DB): neues idempotentes Script
-
🚨 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 jetztconfirm: "FACTORY-RESET-BESTAETIGT"als String erzwungen, sonst HTTP 400. Frontend-API schickt den exakten String mit. - M1 – Settings Mass Assignment:
PUT /api/settingsundPUT /api/settings/:keynahmen JEDEN Key-Value-Pair an (superAdminEmail,debugMode,allowedOriginsetc. landeten direkt in der DB). Fix: WhitelistALLOWED_SETTING_KEYSinappSetting.service.ts, HelperisAllowedSettingKey(). 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, dererror/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: neuerstripHtml()-Helper, vonpickCustomerUpdate/CreateundpickUserUpdate/Createauf 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-resetmit{},{confirm:true},{confirm:false}→ HTTP 400, DB unangetastetPUT /settings {superAdminEmail,debugMode,allowedOrigins}→ 400 + Keys aufgezählt; gültige Keys → 200PUT /users/99999→"Operation fehlgeschlagen"statt Prisma-Stack; Server-Log behält OriginalPUT /customers/3 {companyName:"<script>...</script>EvilCorp"}→ gespeichert als"EvilCorp";<img onerror>weg
- C2 KRITISCH – Factory Reset ohne Bestätigung:
Eingeloggter Admin konnte mit leerem oder beliebigem Body
(
-
🚨 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]undrepresentedCustomers: [{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:
customerLoginundgetCustomerPortalUser(= /me + Refresh-Pfad) filternrepresentingForjetzt zusätzlich übergetAuthorizedCustomerIds()– nur Beziehungen mitisGranted: truelanden im Token und in /me. - Verifiziert: Customer 1 (vertritt 2,3 aber alle Vollmachten
widerrufen) → JWT.representedCustomerIds =
[], /me ebenfalls.
- Fix:
- MEDIUM – DTO-Leak in embedded Objekten:
GET /customers/:idlieferte zwar Customer-Top-Level sanitisiert, abercontracts[]darin enthielt weiterhincommission,notes,portalPasswordEncrypted,nextReviewDate. Analognotesauf embedded customer in/contracts/:id.- Fix:
sanitizeCustomer(Strict)ruft jetztsanitizeContract(Strict)für jedes Element incontracts[]auf.noteszuPORTAL_HIDDEN_CUSTOMER_FIELDSergä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);portalPasswordEncryptedist generell entfernt (Klartext nur via/contracts/:id/passwordmit Audit-Log).
- Fix:
- LOW –
/tasks?customerId=X200 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.
- MEDIUM – Stale Token nach Vollmacht-Widerruf:
Selbst ein FRISCHER Portal-Login lieferte JWT mit
-
🚨 Pentest Runde 7 (Anschlussrunde) – Information-Disclosure + Input-Validation
- MEDIUM – Interne Felder in Portal-Responses:
sanitizeCustomerStrictstrippt jetzt zusätzlichportalTokenInvalidatedAt,portalLastLogin,portalPasswordMustChange,lastBirthdayGreetingYear,privacyPolicyPath,businessRegistrationPath,commercialRegisterPath.- Neue
sanitizeContract/sanitizeContractStrict/sanitizeContracts(Strict): entferntportalPasswordEncrypted(immer; ist nur über den dedizierten/password-Endpoint mit Audit-Log abrufbar) und für Portal- User zusätzlichcommission,notes,nextReviewDate. getContract+getContractsrufen jetzt die passende Sanitize-Variante je nachreq.user.isCustomerPortalauf; Mitarbeiter sehen weiterhin commission/notes (Admin-Workflow), nurportalPasswordEncryptedist 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')→6hat alle Endpoints durchgewunken. Neuer middleware inindex.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/3weiterhin 200,/contracts/1abc/history→ 400, normaler Pfade/audit-logs/customer/3→ 200.
- Live-verifiziert:
- INFO – Login-Rate-Limit „nach 6 nicht aktiv":
Code-Stand
limit: 10fürloginRateLimiter, lokal verifiziert: 11. Versuch = 429. Pentester sah vermutlich noch alten Build oder eine andere Lokation (PW-Reset hatlimit: 5). Kein Code-Change.
- MEDIUM – Interne Felder in Portal-Responses:
-
🛠 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 nachtype = RATE_LIMIT_HITim 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)undpasswordResetRateLimiter.resetKey(ip)auf – exposiert vonexpress-rate-limitv7. 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-limitsmit 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.
-
🚨 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/canAccessStressfreiEmailgesichert – keine Lücke gefunden. GET /customers/:id/portal/password(Klartext-Portal-Passwort- Abruf): hatte KEINENcanAccessCustomer-Check. Fix: eingefügt. Defense in depth gegen versehentlich falsch vergebenecustomers: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-tokenliefert ein kurzlebiges JWT mittype: 'download'undexp: 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).
fileUrlundgetAttachmentUrlsind 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.
- Neuer Mechanismus:
- Credential-Endpoints (Contracts password/internet/sip/simcard +
Stressfrei mailbox/send/reset-password): ALLE bereits durch
-
🚨 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:canAccessCachedEmailim Controller. - KRITISCH-02
GET /customers/:customerId/representatives/search: keincanAccessCustomerauf 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:canAccessContractin allen vier History-Handlern. - HOCH-03 Mailbox-Endpoints:
mailbox-accounts,unread-count,contracts/:id/emails/folder-countsohne Check. Fix:canAccessCustomerbzw.canAccessContractin allen drei. - HOCH-04 Live-Vollmacht-Check in Tasks:
getTasks,createSupportTicket,createCustomerReply,getAllTasks,getTaskStatsprüften nurrepresentedCustomerIds.includes(...)aus dem JWT – widerrufene Vollmachten hatten weiter Zugriff (JWT lebt bis zu 15min nach Widerruf). Neuer HelpergetPortalAllowedCustomerIds()inaccessControl.tsrufthasAuthorization()live ab. AuchupdateCustomerConsent(GDPR) auf diesen Pfad umgestellt. - MITTEL-01
confirmPasswordResetKlartext-Speicherung: Self-Service-Reset speicherteportalPasswordEncrypted = encrypt(pw). Klartext-Speicherung ist nur für Admin-OTPs sinnvoll. Fix: Field auf null, zusätzlichportalPasswordMustChange = false. - MITTEL-02 Pagination-Total leakt globale Kunden-Anzahl:
GET /customersgabtotal: 4271auch wenn Portal-User nur 1 Kunde sah. Fix:customer.service.tserweitert umallowedIds-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, eigenecustomers/3→ 200, pagination.total für Portal = 1 (statt 3), Customer 1 mit widerrufener Vollmacht → 0 fremde Verträge.
- KRITISCH-01
-
🚨 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- passwordaufrufen und das eigene Passwort ohne Kenntnis des alten ersetzen. Der OTP-Flow-Endpoint hatte den CheckportalPasswordMustChange === truenicht. - Konsequenz: Bei XSS oder kurzlebigem Token-Diebstahl konnte ein Angreifer das Passwort dauerhaft übernehmen.
- Fix: Eine Zeile in
auth.controller.ts–prisma.customer.findUniqueaufportalPasswordMustChange, beifalse→ 403 "Nicht erlaubt". - Live-verifiziert: ohne Flag → 403; mit Flag (nach
send-credentials) → 200, danach Flag automatisch zurück auf
false→ erneuter Aufruf → 403.
- Realer Angriff: Jeder Portal-User konnte jederzeit mit
seinem eingeloggten Token
-
Pentest Runde 5 – NIEDRIG: consentHash + Public-Grant-Response
consentHashwurde überGET /api/customers/:idzurü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: inSENSITIVE_CUSTOMER_FIELDSaufgenommen. Wer ihn legitim braucht, holt ihn über/gdpr/customer/:id/consent-status(eigener Check).POST /api/public/consent/:hash/grantgab den vollenCustomerConsent[]-Array inkl. IP-Adressen undcreatedBy(Kunden-Name) zurück. Fix: Response auf{ granted: <count> }reduziert. Frontend nutzt eh nursuccess-Flag.- Live-verifiziert:
consentHash: nullin customer-Response,consentHashweiterhin in/gdpr/.../consent-status, Grant-Response liefert nur{granted: 4}ohne Extra-Keys.
-
🚨 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. Diecontracts:read-Permission haben aber auch Portal-User → Endpoint war erreichbar. - Fix: Service-Signatur erweitert auf
getCockpitData({ customerIds? }). WenncustomerIdsgesetzt sind, werden Haupt-Vertrags-Query, Consent-Maps, Ausweis- Warnungen und gemeldete Zählerstände allesamt auf diese IDs eingeschränkt. Controller bestimmtcustomerIdsanalog zugetContracts: beiisCustomerPortal→ 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.
- Realer Angriff: Portal-User Max bekam mit seinem Token
-
🚨 Pentest Runde 3 – drei Findings gefixt
- KRITISCH –
POST /api/developer/setupohne Auth (Privilege Escalation): Endpoint war komplett ohne Authentifizierung erreichbar und konnte der Admin-Rolle diedeveloper:access- Permission verleihen → kompletter DB-Zugriff über/developer/*. Fix: Endpoint ersatzlos gelöscht. Manuelles Setzen geht weiterhin überprisma/add-developer-permission.ts(CLI). Live-verifiziert:POST /api/developer/setup→ HTTP 404. - HOCH – Customer-Login DoS auf Prod (fehlende Migration):
portalPasswordMustChangewar im Code, aber prod-DB kannte die Spalte nicht → Prisma warf bei jedem Kunden-Login. Root Cause: in dieser Session wurdeprisma db pushbenutzt (kein Migration- File). Fix: handgenerierte Migration20260516173552_portal_password_must_change/migration.sql(viaprisma migrate diff+migrate resolve --applied). Verifiziert durch shadow-DB-Reset +migrate deploy: Spalte landet korrekt in einer frischen DB.entrypoint.shführtmigrate deploybeim 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.messagedirekt zurückgegeben → Tabellen-/Spaltennamen leakten. Fix: Whitelist-FiltersafeLoginError()inauth.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.
- KRITISCH –
-
🔐 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:
- Admin klickt Zugangsdaten versenden → Flag wird gesetzt, Mail-Template weist explizit auf „Einmalpasswort" hin.
- Kunde loggt sich mit dem OTP ein → Backend gibt
mustChangePassword: trueim Login-Response zurück UND konsumiert das OTP sofort: setztportalPasswordHash = nullundportalPasswordEncrypted = null. Ein zweiter Login mit demselben Passwort schlägt fehl (401). - Frontend (
ProtectedRoute) siehtmustChangePassword=trueund leitet auf/change-initial-passwordum – egal welche Route der Kunde aufrufen will, er kommt nicht weiter. - Auf der Seite gibt er ein neues, komplexes Passwort vor (Live-Hint mit ✓/○, dieselben Regeln wie Backend).
POST /api/auth/change-initial-portal-passwordspeichert neuen Hash, löscht das Encrypted-Feld (Admin kann das eigene Passwort des Kunden nicht mehr im Klartext lesen), setztportalTokenInvalidatedAt = now()undportalPasswordMustChange = false.- 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" →
mustChangewird automatisch wiederfalse. 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.
-
🔐 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()inpasswordGenerator.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 PasswortHallo123!Test→ HTTP 200, Generator-Endpoint liefert 16-Zeichen-Passwort, Send-Credentials versendet Mail nur bei portalEnabled=true.
-
🌐 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), sonstloopback(Direkt-Verbindungen lokal). - Live-verifiziert: req.ip zeigt jetzt die echte Browser-IP statt der NPM-IP, Threshold-Events triggern korrekt.
- Problem: Rate-Limiter und Security-Monitor haben statt der
echten Client-IP nur die NPM-IP (
-
🚨 KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)
- Realer Angriff erfolgreich durchgespielt: Portal-User konnte über
/api/stressfrei-emails/{id}/credentialsdie kompletten Klartext- IMAP/SMTP-Zugangsdaten der Mailbox eines anderen Kunden abrufen. - Root Cause: der Haupt-Endpoint
GET /:idhattecanAccessStressfreiEmail-Check, die 8 Sub-Endpoints unter:id/*hatten alle KEINEN Ownership-Check (nurauthenticate + 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:
canAccessResourceByCustomerIdemittiert bei jedem Fehlversuch automatisch einACCESS_DENIED MEDIUM-Event → Threshold-Detection (>5 in 5 min) erzeugtCRITICAL 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.
- Realer Angriff erfolgreich durchgespielt: Portal-User konnte über
-
🛡️ 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 (
Securebei HTTPS_ENABLED,SameSite=Strict,Path=/api/auth). JavaScript hat keinen Zugriff → XSS kann ihn nicht klauen. - Backend:
signAccessToken/signRefreshTokenmittype-Claim als Unterscheidung; Auth-Middleware lässt nurtype=accessdurch- Login + Customer-Login setzen Cookie + geben Access im Body
POST /api/auth/refreshliest Cookie, gibt neuen Access aus, rotiert Refresh-Cookie, prüfttokenInvalidatedAt(sofortige Invalidation bei Rolle-Ändern/Logout)- Logout löscht Cookie + setzt
tokenInvalidatedAt cookie-parserals neue dependency
- Frontend:
api.ts: in-memorytokenStore+ axios-Interceptor mit Auto-Refresh-Retry bei 401 (single-flight gegen Concurrent-Requests)AuthContext: beim App-Start/auth/refreshaufrufen → 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 aufgetAccessToken()(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.
-
🔒 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 mitsensitivity: 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).
-
↗ 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.
-
🔍 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/emailsnimmt die Filter als Query-Parameter entgegen,getCachedEmailsübersetzt sie in eine Prismawhere-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.
-
🔁 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
defaultForwardEmailin den Provider-Settings. - Bei
hasMailbox: truewird 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 auchupdateMailboxPassword(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 auftruekorrigiert (historischer Bug: Flag wurde beimcreateEmailmitprovisionAtProvider: truenie gesetzt – jetzt behoben + Backfill via Sync). - Erfolgs-/Fehler-Meldungen via
react-hot-toast(stattalert()) 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.
- 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
-
🛡️ Pentest-Hardening-Runde 11: Header-Hygiene
- HSTS-Doppel-Header (18× low im Audit): Helmet's
Strict-Transport-Securitykomplett deaktiviert. Der Nginx Proxy Manager vor der CRM-VM setzt HSTS bereits, doppelter Header verletzte RFC 6797. - Cache-Control (≥10× info im Audit):
/api/*bekommtno-store(sensible JSON-Daten), SPA-HTML (/,/sitemap.xml,/robots.txt,/vite.svg) bekommtno-store, must-revalidate(sonst hängt Browser an alter index.html fest nach Deploy),/assets/*(Vite-Build mit Content-Hash im Filename) bekommtpublic, max-age=31536000, immutable. - CSP No-Fallback-Direktiven (2× medium):
worker-src,manifest-src,media-srcexplizit 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/*.jsund SPA- Fallback-Pfade alle wie erwartet.
- HSTS-Doppel-Header (18× low im Audit): Helmet's
-
🐛 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.
- CSP-Direktive
-
🔁 Factory-Defaults Sync-Scripts (dev ↔ prod ↔ Image)
./factory-export.shzieht eine ZIP per API infactory-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-builtinentpackt die ZIP zusätzlich nachbackend/factory-defaults/(vorher aufgeräumt). Damit landet sie beim nächstendocker-compose up --buildals 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).
-
🚀 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.tsein – steuerbar überFACTORY_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).
- Inhalt von
-
📦 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:defaultsbleibt 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.
- Datenschutzerklärung, Impressum, Vollmacht-Vorlage und Website-Datenschutz
werden jetzt mit ins Factory-Defaults-ZIP gepackt (
-
🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar
- Mass-Assignment-Whitelist (
pickUserUpdate) hathasGdprAccess/hasDeveloperAccessrausgefiltert → 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).
- Mass-Assignment-Whitelist (
-
🔒 HTTPS-only-Header per Flag (
HTTPS_ENABLED)- HSTS +
upgrade-insecure-requests(CSP) sperrten den Browser bei direktemhttp://ip:port-Zugriff aus (ERR_SSL_PROTOCOL_ERROR). - Beide Header default OFF, kommen nur mit
HTTPS_ENABLED=true(sobald TLS-Reverse-Proxy davor steht).
- HSTS +
-
🗃️ Prisma-Migrations-System (statt
db push)- Initial-Migration
0_initaus aktuellem Schema generiert (prisma migrate diff --from-empty --to-schema-datamodel). - 24 alte gedriftete Migrations gelöscht – frischer Start.
migration_lock.tomlfür MySQL hinzugefügt.- Container-Entrypoint umgebaut:
- Auto-Baseline-Detection: bestehende DB ohne
_prisma_migrations→migrate resolve --applied 0_initläuft automatisch. - Statt
db push --accept-data-lossjetztmigrate deploy(idempotent, datenerhaltend, keine stillen DROPs mehr).
- Auto-Baseline-Detection: bestehende DB ohne
- 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.
- Initial-Migration
-
🔄 Automatische Vertrags-Status-Übergänge
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
status=ACTIVEundendDate < heute→EXPIRED(mit Audit-Log). - Beim Upload der Kündigungsbestätigung (
cancellationConfirmationPath): wenn Vertrag aktuellACTIVE→ aufCANCELLEDsetzen (Audit-Log). Frontend fragt per Modal das Bestätigungs-Datum ab (Default: heute), wird direkt alscancellationConfirmationDategespeichert. Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er für Vertragsänderungen (nicht echte Kündigungen) gedacht ist, setzt abercancellationConfirmationOptionsDateanalog. - Beim Upload einer
Lieferbestätigung(ContractDocument via direkt-Upload oder Email-Anhang-Import): wenn Vertrag aktuellDRAFT→ aufACTIVEsetzen +startDateauf das erfasste Lieferdatum (falls leer). Frontend zeigt Datums-Input conditional, wenn Typ "Lieferbestätigung" ausgewählt ist. - Keine neuen Status eingeführt:
cancellationSentDatevs.cancellationConfirmationDategenügen, um "gesendet vs. bestätigt" abzubilden.ACTIVEbleibt bis zur Bestätigung.
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
-
🛡️ Security-Hardening vor Production-Deployment (10 Runden)
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs: SECURITY-HARDENING.md
- Erste 2 Runden zusätzlich ausführlich in 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)
-
🎉 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
- Passwort vergessen-Flow (Login → "Passwort vergessen?" Link)
-
Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider
- Neues Feld
customerEmailLabelam 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.deStrings 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
- Neues Feld
-
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:defaultsliestbackend/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
-
Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
- Als Dokument (in feste Slots wie Kündigungsschreiben) – wie bisher
- Als Vertragsdokument – neu, mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
- 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
saveAttachmentAsContractDocumentfür die flexible ContractDocument-Tabelle
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
-
Geburtstag-Management-Modal in Kundenstammdaten
- Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
- Gruß zurücksetzen: setzt
lastBirthdayGreetingYearauf 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
-
Anrede-Verhältnis Du/Sie pro Kunde
- Neues Feld
useInformalAddressin 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")
- Neues Feld
-
Geburtsdatum + Geburtsort auch bei Firmenkunden
- Felder werden jetzt unabhängig vom Kundentyp angezeigt
- Ermöglicht z.B. Geburtstage für Ansprechpartner bei Firmen
-
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
-
Typspezifische Zusatzinfos in Vertragslisten
- Strom/Gas → "Lieferadresse: ..."
- DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
- Mobilfunk → "Rufnummer: ..."
- KFZ → "Kennzeichen: ..."
- Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
-
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
-
Zweitarif-Zähler (HT/NT) bei Strom + Verbrauchsberechnung
-
Datumsformate vereinheitlichen (01.01.2026 statt 1.1.2026)
-
Audit-Log aussagekräftig (Vorher/Nachher bei allen Änderungen)
-
Impressum + Website-Datenschutzerklärung im Kundenportal
- Editor in Einstellungen
- Vorschlagstexte
-
Consent-Bestätigungs-Flow per Email
- Alle Hebel müssen gesetzt sein
- Bestätigungsbutton + Bestätigungsemail
-
Vertragsdokumente-Upload (Auftragsformular, Lieferbestätigung, Vertragsunterlagen als PDF/PNG)
-
Bug: Stressfrei-Email im Auftragsgenerator (funktioniert jetzt im Vertrag)
-
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)
-
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.)
-
Gruppenauswahl Liefer-/Rechnungs-/Eigentümer-Adresse im Auftragsgenerator
-
Objekttyp + Lage + Lage des Anschlusses bei Festnetz-Verträgen (DSL/Glasfaser/Kabel)
-
Bankverbindung-Fallback im PDF-Generator (neueste aktive Bankverbindung des Kunden)