Beim Anlegen einer Adresse mit echter Mailbox (IMAP/SMTP) wird das frisch generierte Passwort jetzt immer explizit am Provider gesetzt (updateMailboxPassword direkt nach dem Provisioning). Behebt den Fall, dass die Adresse beim Provider bereits als reine Weiterleitung existierte: dann kehrte provisionEmailWithMailbox frueh mit success zurueck, ohne je ein Postfach-Passwort zu setzen. Im CRM lag dann ein verschluesseltes Passwort, das der Provider nicht kannte -> IMAP/SMTP-Login schlug fehl. Jetzt stimmen CRM und Provider ueberein. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 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
-
đ§ 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. - 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:
jâavâascript: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)