Globaler Error-Handler (index.ts:461) matcht /sind erlaubt|nicht erlaubt/i auf 415. Die 70.1-Reject-Message "... WebP erlaubt" (ohne "sind") rutschte durch und landete bei 500 + Error-Log-Spam. Fix: "... WebP-Dateien sind erlaubt" macht den Regex happy. Andere Routes nutzen alle schon dieselbe Phrase.
98 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
-
đ 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)