Auf Wunsch des Pentesters (R117-Nachtrag). Das Umschalten des
portalCredentialsNotRequired-Flags emittiert jetzt zusätzlich zum
generischen Contract-Update-Diff ein dediziertes UPDATE-Event
unter resourceType 'ContractPassword' – landet damit in derselben
CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads.
Motivation: das Setzen des Flags räumt server-seitig
portalUsername, portalPasswordEncrypted und stressfreiEmailId auf
NULL. Diese Löschung soll unabhängig vom generischen Diff sofort
sichtbar sein. Rücknahme wird ebenfalls geloggt.
Details enthält alte + neue Flag-Werte plus Bool-Marker, welche
Anmeldedaten vor dem Opt-out belegt waren – keine Klartext-
Leckage im Log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dritter Radio-Button in der Zugangsdaten-Card des Vertragsformulars.
Wenn gesetzt, unterdrückt das Cockpit die Warnung „Portal-Zugangs-
daten fehlen" für diesen Vertrag – für Anbieter ohne Portal oder
Kunden, die bewusst keine Zugangsdaten pflegen. Verstopft das
Cockpit sonst dauerhaft.
Neues Feld Contract.portalCredentialsNotRequired (Boolean, default
false) + idempotente Migration (ADD COLUMN IF NOT EXISTS). Bestand
bleibt auf false, Warnung greift wie bisher.
Beim Umschalten auf Opt-out werden portalUsername,
portalPasswordEncrypted und stressfreiEmailId server-seitig
explizit auf NULL gesetzt – Datenhygiene, damit keine verwaisten
Anmeldedaten in der DB stehen bleiben.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der bisherige alert() maskierte die eigentliche Ursache (Vertrag
nicht gefunden / Kein Zugriff / Decrypt-Fehler). Ersetzt durch
Toast mit Backend-Message + console.error mit Stacktrace, plus
Info-Toast wenn die SIM in der DB gar keine PIN/PUK hinterlegt hat.
Endpoint (GET /contracts/simcard/:id/credentials) und Auditing
unverändert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Icon neben "Portal Benutzername" öffnet die Stressfrei-/Corporate-
Adressverwaltung des Kunden in neuem Tab, ohne das Vertragsformular
zu verlassen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug 1 (Header gestaucht): kompakteres Vertical-Spacing im
Header (px-4 pt-3 pb-2 statt p-4, space-y-2 statt -3),
Subject auf max 2 Zeilen begrenzt mit vollem Titel im
tooltip. min-h-0 auf der Flex-Container, damit der Body
tatsächlich flex-1 claimt.
Bug 2 (Link unsichtbar): Corporate-CTAs stylen ihre Buttons
gerne mit inline style="color:white;background:green"; der
zugehörige <style>-Block wird von DOMPurify entfernt, das
inline-style am <a> überlebt aber – Ergebnis: weißer Link
auf weißem CRM-Hintergrund, unsichtbar. Fix: von <a>-Tags
werden style und class gestrippt, dann greift Browser-
Default (blau/violett + unterstrichen) wieder.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Ein DEACTIVATED-Vertrag (manuell archiviert) zeigte im
Cockpit weiter "Vertragsende: Vertrag seit 79 Tagen abgelaufen!"
– sinnlos, weil der Mitarbeiter den Vertrag ja aktiv beendet
hatte.
Fix: skipDeadlines-Guard, der bisher nur ONGOING abfing, greift
jetzt auch bei DEACTIVATED und CANCELLED. Beide sind terminale
Status – laufende Fristen ergeben da semantisch keinen Sinn.
Bewusst weiterhin AKTIV:
- Block 13a (Schlussrechnung fehlt): feuert wegen dieser Status,
nicht trotzdem.
- Block 8 (DSGVO/Consent): läuft pro Kunde, nicht pro Vertrag.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pentester meldete 403 auf POST/PUT/DELETE /api/contract-categories/*
für Admin trotz vorhandener contract-categories:*-Perms. Vermutete
Regression aus R110, war aber schon seit dem allerersten Commit
so: Route verlangte developer:access, während der Permission-Seed
den Admins contract-categories:* gibt.
Fix: Route auf contract-categories:create/update/delete umgestellt,
konsistent mit den sechs Peer-Katalog-Endpunkten (platforms/
tariffs/cancellation-periods/… nutzen alle ressourcen-spezifische
Perms). Kommentar aktualisiert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MEDIUM: PUT /api/stressfrei-emails/:id und 6 weitere Update-
Endpunkte (platform, tariff, contractCategory, cancellationPeriod,
contractDuration, email-providers) reichten req.body ungefiltert
an Prisma. Gleiche Bug-Klasse wie das gefixte M1-Finding, sieben
Stellen mehr. Nachgewiesen via provisionError-Feld ausserhalb des
TS-Types.
Fix: sieben Whitelists + pickXxxUpdate()-Helper in sanitize.ts,
in den jeweiligen Controllern eingehängt. Reuse der bewährten
pick()-Infrastruktur (Customer/User seit Runde 7).
EmailProvider bewusst OHNE stripHtmlFromStrings, weil Passwörter
und API-Keys legitim Sonderzeichen enthalten dürfen.
Doku: SECURITY-HARDENING.md § Runde 110 + docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
todo.md: drei letzte Bug/Feature-Einträge
- AutosaveDateInput (Kündigungsdatum-Fokus-Sprung)
- IMAP-Humanizer (Anhang + Sync)
- Bankkarte-Beschreibung in Vertrags-Ansicht + Select
TESTING.md: neuer Abschnitt "E-Mail-Client (IMAP/Anhänge)"
mit Testfällen für Sync-Fehler, Anhang-Download-Fehler und
Autosave-Datumsfelder.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: bei Eingabe der Jahreszahl im Kündigungsbestätigungs-Datum
sprang der Fokus raus und die Seite scrollte hoch. Ursache:
<input type=date> onChange feuert bei jedem Tastendruck. Sobald
"18.08.0002" ein gültiges Datum ergab, feuerte der Mutation-Aufruf
eine PUT-Request, die Server-Antwort re-renderte den Input und
der Cursor landete am Feldanfang – der Rest der Jahreszahl kam
im nächsten Feld an.
Fix: neue kleine AutosaveDateInput-Komponente – hält den Wert
während der Eingabe lokal, feuert onSave erst bei onBlur oder
Enter. Escape verwirft die Änderung. Kein Save bei unverändertem
Wert.
Angewendet auf beide betroffenen Stellen in ContractDetail
(Bestätigung erhalten am – Kündigungsschreiben + Options-Variante).
Andere Datumsfelder der App laufen entweder über react-hook-form
(batched, save auf Submit) oder halten den Wert lokal – die sind
vom Bug nicht betroffen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der User sah beim Postfach-Sync stumpf "Command failed" – realer
Grund war ein abgelaufenes Postfach-Passwort. Der Bug betraf
Anhang-Download UND Sync und war der gleiche wie 2 Commits zuvor:
imapflow wirft `new Error('Command failed')` und legt Details in
`.responseText`/`.responseStatus` ab, wir haben sie nirgends
gelesen.
Fix: humanizeImapError() als zentraler Helper in imapService:
- Extrahiert responseText/responseStatus aus imapflow-Errors
- Erkennt Auth-Fehler ("authentication failed", "invalid
credentials", NO+auth) und liefert klare Meldung mit
Handlungsanweisung: "Passwort stimmt nicht mehr, bitte
Zugangsdaten synchronisieren".
- Deckt zusätzlich Netzwerk/TLS/Timeout/UID-Stale ab.
Angewendet auf:
- fetchAttachmentInner (Anhang-Fetch)
- downloadAttachment-Controller (Response an UI)
- syncEmailsForAccount (Sync-Ergebnis + Toast)
Damit sieht der User im Toast künftig statt "Command failed" die
tatsächliche Ursache – gleicher Mechanismus für Sync und
Anhang-Download.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Anhang-Download schlug mit "Fehler beim Herunterladen des
Anhangs: Command failed" fehl – ohne jeden Kontext, warum.
Ursache: imapflow wirft bei jedem IMAP-NO/BAD-Response nur
`new Error('Command failed')` und legt den echten Server-Grund
in `.responseText` / `.responseStatus` ab. Unser Code las nur
`.message` und verlor damit alle Information.
Fix:
- fetchAttachmentInner extrahiert responseText/responseStatus
aus dem imapflow-Error und packt sie in die geworfene Meldung.
- downloadAttachment-Catch macht das gleiche, damit auch andere
IMAP-Fehler (mailboxOpen, connect) mit sinnvollem Text
durchkommen.
- Zusätzlicher Friendly-Mapping-Fall für "no longer exist" /
"no such message" → klare Meldung, dass die E-Mail-Liste
neu synchronisiert werden sollte.
Damit sieht der User im UI jetzt z.B. "NO Some of the requested
messages no longer exist" statt "Command failed" und weiß, dass
ein Resync hilft.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ContractDetail: Bankkarten-Card zeigt jetzt die description
kursiv unter Bankname (whitespace-pre-line für mehrzeilige
Notizen).
ContractForm: Bankkarten-Select hängt die Beschreibung kompakt
an das Label an (Zeilenumbrüche flach, auf 60 Zeichen gecapped)
– hilft beim Unterscheiden mehrerer Karten desselben Kunden.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rechts neben dem Stressfrei-Adressen-Dropdown sitzen jetzt zwei
kleine Icon-Links (nur bei gewähltem Kunden sichtbar):
- ExternalLink → öffnet den Stressfrei-Tab des Kunden im neuen Tab
(dort kann man die Adressen anlegen/pflegen)
- Mail → öffnet den E-Mail-Postfach-Tab des Kunden im neuen Tab
Damit muss man beim Vertragserstellen nicht mehr die Seite
wechseln, um schnell eine Stressfrei-Adresse anzulegen oder ins
Postfach zu springen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Beim Anlegen einer neuen Stressfrei-E-Mail-Adresse: sobald die
Checkbox "Beim Provider anlegen" aktiv ist, wird jetzt automatisch
auch die zweite Checkbox "Echte Mailbox erstellen" mitgeschaltet.
Damit läuft `createEmail` mit `createMailbox: true` durch –
Backend generiert per generateSecurePassword ein Kennwort,
schickt es via -passwd an Plesk und speichert es verschlüsselt.
Vorher musste man beide Häkchen manuell setzen und übersah leicht
die zweite Checkbox → Adresse landete nur mit Weiterleitung ohne
IMAP/SMTP-Zugang bei Plesk.
Falls jemand nur eine reine Weiterleitung ohne Postfach will,
kann er die zweite Checkbox weiterhin abwählen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neues optionales Freitext-Feld `description` (TEXT NULL) auf
BankCard – z.B. "Geschäftskonto", "gemeinsames Konto mit Partner".
Migration mit IF NOT EXISTS.
Backend service create/update nimmt description entgegen.
Frontend:
- BankCard-Type um description ergänzt.
- BankCardModal: neue Textarea zwischen Ablaufdatum und Aktiv-
Checkbox mit Placeholder-Hilfetext.
- Bank-Kartenübersicht zeigt die Beschreibung kursiv unter den
bestehenden Feldern, falls gesetzt (whitespace-pre-line für
mehrzeilige Notizen).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INFO-Finding: GET/PUT /api/customers/:id/salutation-preference
warfen bei nicht-existierendem Customer 500 statt 404.
canAccessCustomer prüft für Staff-User nur den Portal-Flag, kein
Existenz-Check. Der Service warf `new Error('Kunde nicht
gefunden')`, was der Controller-Catch generisch auf 500 mappte.
set/clear kamen als Prisma-P2003 (FK-Constraint) durch.
Fix:
- Neuer Helper assertCustomerExists() im customer.service, wirft
ApiError(404, ...). Wird von get/set/clearSalutationPreference
aufgerufen.
- Die drei Controller-Catches respektieren jetzt
ApiError.statusCode → 404 kommt sauber durch, alle anderen
Fehler bleiben bei 500.
Doku: docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INFO-Finding: die interne CRM-Vertragsnummer war im Update-Path
nicht durch validateContractIdentifier abgedeckt. <script>… →
stripHtml → leerer String → Vertragsnummer silent überschrieben
(R87.1-Pattern auf einem sechsten Feld).
Fix: contractNumber zu CONTRACT_IDENTIFIER_FIELDS hinzugefügt.
Bestehende Whitelist (^[A-Za-z0-9_\-/. ]{0,100}$) und Raw-Input-
Validierung greifen automatisch. Legitime generierte Nummern
(GAS-MPVJ4P10FE8) bleiben OK, <script>/Overflow/CRLF/Tags → 400.
Kein Migration- oder Frontend-Change – der Fix nutzt den
bestehenden R86-Mechanismus.
Doku in SECURITY-HARDENING.md § Runde 102 und docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neue Tabelle UserCustomerSalutation (PK userId+customerId,
preference 'DU'|'SIE'). Fehlender Eintrag → Fallback auf
Customer.useInformalAddress (Kunden-Default).
Backend:
- customerService: get/set/clearSalutationPreference mit Fallback-
Logik. Response enthält immer effektive Präferenz + `source`
('user' | 'customer-default'), damit die UI den Standard-Text
anzeigen kann.
- customerController: 3 Endpunkte GET/PUT/DELETE
/:customerId/salutation-preference. userId aus dem JWT, canAccessCustomer
greift.
Frontend:
- customerApi: 3 neue Methoden.
- CustomerDetail: neues Feld "Anrede für mich" mit Du/Sie-Toggle
und Zurücksetzen-Link. Wird nur Mitarbeitern angezeigt, nicht
Portal-Usern.
- Bestehendes Feld "Anrede per" bekommt Hinweis "Standard für alle
Mitarbeiter", damit die Semantik der beiden Felder klar ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Enum-Wert ONGOING neu, MariaDB-Migration (ans Ende gehängt, damit
keine Rows umgeschrieben werden). Prisma Client neu generiert.
Cockpit-Logik: ONGOING wird in die Query mit reingeholt (damit
DSGVO-/Consent-Warnungen weiter greifen), aber alle Fristen-
Blöcke pro Vertrag geskippt:
- Kündigungsfrist
- Vertragsende
- Zwischenrechnung-Frist
Kein Skip für Daten-Qualität (fehlende Adresse, Bank, Portal-
Zugang etc.) – die sind auch für unbefristete Verträge relevant.
Frontend: statusLabels/statusVariants/statusDescriptions in
ContractDetail, ContractList, CustomerDetail und
ContractDetailModal um ONGOING ergänzt. Status-Select im
ContractForm bekommt die neue Option zwischen ACTIVE und
CANCELLED.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drei Frontend-Guards raus, die verhindert haben, dass man an
erledigten Aufgaben noch etwas machen kann:
- TaskList: der Reply-/Subtask-Input war für erledigte Aufgaben
komplett ausgeblendet. Jetzt sichtbar mit angepasstem Placeholder
("Nachtrag zu erledigter Aufgabe…" / "Antwort zu erledigter
Anfrage…").
- ContractDetail: der Subtask-Input war für erledigte Aufgaben aus.
Jetzt sichtbar – man muss die Aufgabe nicht mehr als offen
markieren, nur um einen Nachtrag anzuhängen.
- ContractDetail: der Edit-Button war für erledigte Aufgaben aus.
Jetzt immer verfügbar (Löschen war eh schon da).
Backend hatte keine Restriktion, es waren rein UI-Gate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der axios-Response-Interceptor packt die Backend-Meldung in
new Error(message) um und wirft .response weg. Der Catch-
Handler beim Passwort-Setzen las aber err.response.data.error
(nach dem Interceptor undefined) und fiel deshalb immer auf
"Passwort konnte nicht gesetzt werden" zurück.
Ergebnis: der Admin sah nie den echten Grund – typischerweise
"Passwort erfüllt Mindestanforderungen nicht: mindestens 25
Zeichen" oder "Eigenes Passwort ist falsch".
Fix: err.message lesen. Das ist genau der vom Interceptor
gesetzte String.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Suche und Add haben bisher nur Kunden mit portalEnabled=true zugelassen –
das machte das Feature quasi unnutzbar, weil die meisten Kunden kein
Portal aktiviert haben. Der Zugriff ist ohnehin erst dann effektiv,
wenn der Vertreter ein Portal-Konto bekommt.
- searchCustomersForRepresentative: portalEnabled-Filter raus, dafür
Feld im select mitgeliefert.
- addRepresentative: Portal-Pflicht-Check raus.
- getRepresentedByList: portalEnabled im rep-Include, damit die UI
auch für schon hinterlegte Vertreter das Badge zeigen kann.
- CustomerDetail: gelbes "Portal inaktiv"-Badge in Suchergebnissen
und in der Vertreter-Liste. Hinweistext geändert.
- CustomerSummary-Type: portalEnabled? ergänzt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Flexbox-Items haben default min-width: auto. Ohne overflow-auto
(das ich in 6479598 rausgenommen habe, damit Sticky-Header
funktionieren) wächst der Wrapper mit jedem breiten Kind – lange
E-Mail-Adressen, breite Tabellen, PDF-URL-Anhänge – horizontal
über die Viewport-Breite hinaus.
min-w-0 cappt die Flex-Basis auf die verfügbare Breite, ohne
einen Scroll-Kontext zu schaffen (im Gegensatz zu overflow-auto),
also bleiben die Sticky-Header weiter wirksam.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Content-Wrapper hatte overflow-auto – das machte ihn zum
Scroll-Ancestor jedes Sticky-Elements, obwohl er wegen der
flexbox-min-h-auto-Regel nie selbst scrollte. Sticky-Header
in ContractDetail und ContractForm blieben deshalb nicht oben
kleben, weil der Scroll-Kontext, an den sie gebunden waren,
gar nicht scrollte.
Fix: overflow-auto entfernt. Jetzt scrollt wieder das Window,
Sticky sitzt an der Viewport-Kante. EmailClientTab arbeitet
mit calc(100vh - 240px) und ist von der Änderung nicht
betroffen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ContractDetail: der obere Balken (Vertragsnummer, Kunde-Link,
Aktions-Buttons wie Folgevertrag/Bearbeiten/Löschen) bleibt beim
Scrollen sichtbar. -mx-8 px-8 überbrückt das Layout-Padding,
damit der Balken bündig sitzt.
ContractForm: Heading + Kunde-Zeile sticky oben, Speichern/
Abbrechen sticky unten. Damit muss man beim langen Formular nicht
mehr nach oben oder unten rollen.
ScrollToTopButton: von bottom-6 auf bottom-24 verschoben, sonst
sitzt der runde Nach-oben-Button beim Runterscrollen genau über
dem Speichern-Button im Sticky-Footer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ContractDetail (Vertragsansicht): neben dem Kunden-Link sitzt jetzt
zusätzlich ein ExternalLink-Icon, das die Kundenakte in einem neuen
Tab öffnet. Info-Icon (Schnellansicht-Modal) bleibt wie gehabt.
ContractForm (Neuer/Bearbeiten-Vertrag): bekommt unter dem Heading
dieselbe Kunden-Zeile wie ContractDetail – Customer-Name als Link,
ExternalLink-Icon für neuen Tab, Info-Icon für die
CustomerInfoModal-Schnellansicht. Nur sichtbar wenn schon ein
Kunde gewählt ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R101.1 INFO/funktional: Pentester sieht Content-Disposition:
attachment auch bei ?disposition=inline. Die Logik im Controller
ist korrekt und liefert beim Direkttest gegen echte PDFs
application/pdf, der Pfad lässt sich aber in Prod nicht
reproduzieren.
Refaktoriert:
- Magic-Byte-Check in detectSafeContentType() extrahiert
- File-Descriptor wird in finally garantiert geschlossen
- Short-Read-Fälle (bytesRead < n) explizit geguardet
- console.warn wenn inline angefragt aber Magic-Byte-Mismatch
oder Read-Crash – damit der Fall in Prod-Logs sichtbar wird
falls er wieder auftritt
Sicherheits-Verhalten unverändert: Mismatch → attachment
(Stored-XSS-Schutz aus R30.13).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neben jeder Dokument-Zeile sitzt jetzt ein "Vorschau"-Link mit
ExternalLink-Icon, der die PDF in einem neuen Tab öffnet (via
viewUrl mit Token-Auth, inline-disposition). Klick darauf
schaltet bewusst NICHT die Checkbox um – die Auswahl bleibt,
nur das Dokument geht in einem zweiten Tab auf.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-Bug: bei vielen E-Mails wuchs die Liste links unbegrenzt nach
unten, sodass die ganze Seite gescrollt werden musste.
- ContractEmailsSection: flex-Container von minHeight:400 auf
feste 600px Höhe gestellt. Die linke Liste hatte schon
overflow-y-auto – jetzt greift's auch.
- EmailClientTab: h-full auf calc(100vh - 240px) (mit
minHeight:500) bounded. h-full hat im Tab-Container vorher
nichts gebracht, weil der Parent selbst keine feste Höhe
hatte.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R97.1 LOW: malformed content (null, fehlend, true, "") landete
mit rohem Buffer.from()-Fehlertext in der Response; "" liess
sogar 0-Byte-Anhänge durch.
R97.2 INFO: keine App-Level-Caps für Größe/Anzahl – die im
Frontend dokumentierten 10/25 MB hingen am bodyParser.
Fix: validateAttachments() läuft VOR sendEmail() im Controller:
- max 25 Anhänge
- filename non-empty String, content non-empty Base64, optionaler
contentType als String
- 10 MB pro Datei, 25 MB total (Größen-Schätzung über base64-Länge,
kein Buffer.from während Validierung)
Harte 400 mit klarer Meldung. Sanity-Test 18/18 grün.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Modal ist für Mails AN den Anbieter gedacht – interne CRM-Nummern
interessieren dort niemanden.
- formatCustomerBlock: customer.customerNumber (intern) raus,
stattdessen contract.customerNumberAtProvider rein.
- formatContractBlock: interne contractNumber raus, restliche
Anbieter-/Vertriebsplattform-Nummern bleiben.
- Previews ziehen ebenfalls auf customerNumberAtProvider /
contractNumberAtProvider um, mit Hinweis-Text wenn keine
Anbieter-Nummer hinterlegt ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bank- und Ausweis-Section haben jetzt jeweils zwei unabhängige
Checkboxen statt der bisherigen Section-Checkbox + Sub-Attach:
- Bank: "Letzte 4 IBAN-Stellen einfügen" + "Bankkarte als PDF
anhängen". Text-Variante zeigt nur "IBAN endet auf: XXXX" – keine
volle IBAN/BIC/Bank-Liste mehr (Mail-Hygiene).
- Ausweis: "{Typ}-Nummer einfügen" + "{Typ} als PDF anhängen".
Text-Variante zeigt nur die Nummer, keine Behörde/Daten.
Alle drei Kombinationen "nur Text", "nur PDF" und "beides" sind
damit möglich, "keins von beidem" entspricht der Section-aus.
Schalter sind disabled wenn der jeweilige Wert (IBAN /
documentNumber / documentPath) nicht vorhanden ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
In der "Anrede & Name"-Section neue Radio-Wahl, sobald die
Section aktiv ist:
- Stammdaten-E-Mail (customer.email) – default wenn vorhanden
- Absender-Adresse (Postfach von dem gesendet wird)
- Keine E-Mail einfügen
Wird in den Customer-Block-Builder durchgereicht und ersetzt die
fix verdrahtete customer.email-Zeile. Wenn die Stammdaten-Mail
fehlt, ist der Radio "Stammdaten" disabled und der Default
springt auf "Absender".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Zwei neue Buttons im Compose-Modal (nur sichtbar bei Vertrag-
Kontext):
- Vertragsdokumente: listet alle am Vertrag gespeicherten
ContractDocuments gruppiert nach documentType. Auswahl →
Token-Download via fileUrl → base64 → Anhang.
- Kundendaten einfügen: zeigt Sections nur wenn Daten vorhanden
(Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag, Bank,
Ausweis). Bei Bank/Ausweis zusätzlich Sub-Checkbox "als PDF
anhängen" wenn documentPath vorhanden. Text-Blöcke ans Body-
Ende, PDFs in attachments[]. 25-MB-Limit beidseitig geprüft.
Helpers in composeAttachmentHelpers.ts:
- serverFileToAttachment(path, filename) für Token-URL→Blob→base64
- totalAttachmentBytes mit ~33% base64-Overhead
- sprechende Dateinamen via bankCardAttachmentName /
identityDocAttachmentName
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R95.1 MEDIUM: foo\r\nBcc:evil@x.de → Header-Injection-Vektor
R95.3 LOW: <script>...</script>@x.de → silent stripHtml-Mutation
R95.4 LOW: >190 Zeichen → VARCHAR-Overflow → 500 statt 400
Fix: validatePortalUsername() in sanitize.ts mit Whitelist
^[A-Za-z0-9_\-/.@+ ]{0,100}$. Strukturell sind CRLF, Tab, alle
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
Check. Max 100 → ApiError(400) → R95.4. Raw-Input vor stripHtml
geprüft (R87-Pattern). Eingehängt in sanitizeContractBody.
R95.2 (Email-Format-Pflicht) bewusst NICHT übernommen:
portalUsername ist im Manual-Modus nicht zwingend eine Email
(Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder
Pseudonyme als Portal-Login). Doku in SECURITY-HARDENING.md
§ Runde 95.
Frontend: maxLength={100} am Input als UX-Schicht.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ContractForm: neben dem "Portal Benutzername"-Label sitzt jetzt
ein CopyButton, der je nach Modus den manuellen Eingabewert oder
die ausgewählte Stressfrei-Adresse in die Zwischenablage kopiert.
Erscheint nur wenn der jeweilige Wert nicht leer ist.
EmailClientTab + ContractEmailsSection: rechts neben dem Account-
Selector liegt jetzt ein CopyButton, der die Postfach-Adresse des
aktuell gewählten Mailbox-Kontos kopiert. Funktioniert in beiden
UI-Varianten (Single-Account-Span und Multi-Account-Dropdown).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R93.1 INFO: ?accountId= (explizit-leer) wurde wie ?accountId
weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
Pentester-Spec: leerer String ist keine gültige Zahl.
Fix in parsePositiveIntQuery: nur `v === undefined` ist absent;
'', ' ', alles andere muss parsen. Required + optional Modes
unverändert. Sanity-Test: alle 11 Cases grün.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R91.1 LOW: parseInt('abc') = NaN → der Ternary gab NaN an den
Service, if (NaN) ist falsy → Postfach-Filter fiel weg. Portal-
User mit ungültigem accountId sah Mails aus allen Postfächern
des Kunden für seinen Vertrag (canAccessContract greift weiter,
kein Cross-Customer-Leak).
Fix: zentraler parsePositiveIntParam(), akzeptiert nur positive
Ganzzahlen aus Query-Strings. Eingesetzt auf allen 5 Endpunkten,
die accountId/contractId aus Query lesen – auch da, wo der
Pentester nicht getestet hat (Customer-Inbox, Trash-Count),
weil derselbe Pattern überall stand.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Im Vertrags-Tab (Gesendet/Gelöscht) und im Kunden-Haupt-
Postfach (Gelöscht) wurden Mails aus ALLEN Postfächern angezeigt,
unabhängig vom ausgewählten Postfach. Im Vertrag fehlte zusätzlich
der Vertrags-Filter im Papierkorb.
Backend:
- getEmailsForContract akzeptiert accountId → stressfreiEmailId
- getTrashEmails (controller + service) nimmt {accountId, contractId}
- getFolderCountsForContract bekommt optional stressfreiEmailId und
zusätzlich trash/trashUnread im Result
Frontend:
- API-Client (getForContract/getTrash/getContractFolderCounts) nimmt
Filter entgegen
- ContractEmailsSection reicht selectedAccountId in alle drei Queries
+ queryKey durch. Trash-Badge kommt jetzt aus contract-scoped
Counts statt account-globalem stressfreiEmailApi
- EmailClientTab reicht selectedAccountId in die Trash-Query durch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R89.1 MEDIUM + R89.2 LOW: sanitizeNotes(…, 500) macht silent
slice(0, 500) statt 400, und stripHtml lief vor dem Length-
Check – `<script>…</script>` reduzierte auf "" → null in DB
→ vorheriger Wert silent überschrieben (R87.1-Pattern auf
Adress-Feldern).
Fix: validateProviderAddress() in sanitize.ts – Raw-Input,
max 500 mit ApiError(400), Blacklist <, >, Tab + alle
Control-Chars außer \n. CRLF → LF VOR dem Length-Check, damit
Editoren mit \r\n-Line-Endings nicht doppelt zählen. Eingehängt
in stripProviderStrings für contactAddress/cancellationAddress.
R89.3/R89.4 (Quotes/\n) bewusst akzeptiert – Pentester selbst
sagt "kein Risiko", sind in Adressen legitim.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sieben neue optionale Felder am Provider (contactEmail,
contactPhone, contactFax, contactAddress, cancellationEmail,
cancellationFax, cancellationAddress). Postadressen TEXT,
Rest VARCHAR(191). Migration mit IF NOT EXISTS.
Modal "Anbieter bearbeiten" bekommt neue Sektion "Kontakt &
Kündigung" mit zwei Untergruppen. Backend validiert Emails
gegen isValidEmail (Header-Injection-Schutz), Telefon/Fax
gegen sanitizePhoneField (kein CRLF), Postadressen via
sanitizeNotes mit 500-Cap. Factory-Defaults Export/Import
mitgezogen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R87.1 LOW: stripHtml lief im R86-Fix VOR der Whitelist.
`<b>bold</b>` ging als `"bold"` mit 200 OK durch,
`<script>…</script>` reduzierte auf leeren String → null in DB
→ vorheriger Wert ohne Fehlermeldung überschrieben.
Fix: validateContractIdentifier läuft jetzt direkt gegen den
Raw-Input für die fünf Identifier-Felder. Die strikte Whitelist
lehnt eh alles ab, was stripHtml normalerweise auffangen würde
(Tags, Schemes, Zero-Width, Homoglyphe, Percent-Encoding) –
Defense-in-Depth bleibt, nur ehrlich (400 statt silent-200).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R86.1 LOW + R86.2 LOW: >999-Zeichen liefen in DB-Overflow (500
statt 400), Attribut-Injection (`foo" onerror=…` ohne
umschließenden Tag) überlebte stripHtml.
Fix: validateContractIdentifier() (max 100,
^[A-Za-z0-9_\-/. ]{0,100}$) in sanitize.ts, eingehängt in
sanitizeContractBody. Wirft ApiError(400, …). Literales Space
statt \s → kein CRLF/Tab → kein Header-Injection-Vektor in
CSV-/Mail-/PDF-Export. Greift auf alle fünf Identifier-Felder
(Provider + Sales-Platform). ContractForm-Inputs bekommen
maxLength={100} als UX-Schicht.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contract.orderNumberAtSalesPlatform (VARCHAR(191) NULL) mit
Migration 20260619100000_contract_order_number_at_sales_platform
(IF NOT EXISTS). Form-Input, Detail-Zeile mit Copy-Button,
Audit-Mapping, Renewal-Copy und XSS-Strip-Allowlist analog zu
den bestehenden Sales-Platform-Feldern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Folge-Bug zu 194c864: User löscht Adresse im Modal → DB-Liste
wird kürzer → Plesk-Sync läuft → Auto-Import sieht "c ist in
Plesk aber nicht in DB" → schreibt c zurück in
additionalForwardingEmails → Diff sagt nichts zu entfernen.
Ursache: Auto-Import (Pentest 83.x) lief für alle Sync-Pfade.
Beim Sync-Button ist Plesk→DB-Übernahme gewollt (Bestands-
Migration). Beim User-Add/Remove ist die DB-Liste die explizite
Intent – Auto-Import macht das User-Delete kaputt.
syncForwardingForEmail(id, opts?: { autoImportPleskMembers? })
mit Default true (Sync-Button-Verhalten). setAdditionalForwards
ruft mit false – entfernte Adressen verschwinden jetzt sauber
auch beim Provider.
Follow-up zu a83358b/24e152b. plesk bin mail --help auf Prod zeigt:
- -forwarding-addresses akzeptiert NUR add: und del:, kein set:
→ unser set:-Befehl wurde silent verworfen, Sync hatte nie
Wirkung.
- -mailgroup als Option existiert gar nicht. Plesk nutzt -forwarding
als Mailgroup-Schalter (im --info als "Mailgroup:" ausgegeben, im
CLI als "-forwarding" gesetzt). Mein vorheriges -mailgroup false
triggerte "Unrecognized option".
updateForwardTargets jetzt:
1. Aktuelle Members aus emailExists holen
2. Diff: toRemove = current \ targets, toAdd = targets \ current
(case-insensitive)
3. Wenn toRemove: --update -forwarding-addresses del:<liste>
4. Wenn toAdd: --update -forwarding true -forwarding-addresses add:<liste>
Idempotent, weil add/del Duplikate bzw. nicht-existente ignorieren.
Smoke-Test mit Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
nichts entfernt, nur bzirks@gmx.de hinzugefügt.
Sync zeigt im Prod-Log nur emailExists, kein update – entweder läuft
der Update-Code nicht durch oder Plesk lehnt ihn ab und wir sehen
es nicht (try/catch hat alles geschluckt).
CLI-Params vor dem Call loggen + Plesk-Response vollständig dumpen.
Zusätzlich Response auf code != 0 / stderr-Error prüfen statt
pauschal success=true zurückzugeben.
83.1 MEDIUM: Auto-Import in syncForwardingForEmail rief
assertValidForwardingEmail nicht auf. Plesk-Member wie
attacker@plesk.internal wären ohne TLD-Block-Check (71.1) in
die DB importiert worden. Fix: jeder importierte Member läuft
durch assertValidForwardingEmail, ungültige werden silent gedroppt
+ auf debug-Level geloggt.
83.2 LOW: Self-Forward-Schutz (81.1) griff nur im Add-Pfad. Wenn
Plesk die eigene Adresse als Mailgroup-Member führte, wäre sie
beim Auto-Import in die DB-Liste gerutscht → nach dem Umschalten
auf Forwarding Mail-Loop. Fix: seenKeys mit der eigenen Adresse
initialisieren bevor die Import-Schleife läuft.
83.3 INFO: PII-Log auf console.debug umgestellt (statt console.log).
Smoke-Test mit gemischter Plesk-Liste: legitimer Member importiert,
reservierte TLDs + Self-Mail (exakt + Plus-Tag) abgelehnt,
Customer-Stamm + Default deduped.
Prod-Bug: zusätzliche Weiterleitung eintragen → Toast meldet
Erfolg, Plesk übernimmt nichts. Plesk hat zwei unabhängige
Verteil-Mechanismen, Mailgroup (alte CLI-Anlagen) und Forwarding
(neue). Unser Sync schrieb nur in Forwarding, die alte Adresse
lief aber via Mailgroup → set:-Befehle landeten in ungenutzter
Tabelle. Stage funktionierte, weil dort frisch im Forwarding-
Modus angelegt.
- EmailExistsResult um mailgroupActive/Members + forwardingActive/
Targets erweitert.
- pleskProvider.emailExists parst alle vier Felder aus --info-
stdout (Mailgroup: true|false, Group member(s): ..., Forward
request: ...).
- pleskProvider.updateForwardTargets setzt -mailgroup false dazu –
deaktiviert den Legacy-Mechanismus.
- syncForwardingForEmail holt vorm Plesk-Update die bestehenden
Mailgroup-Members und Forwarding-Targets ab und importiert sie
in unsere additionalForwardingEmails-Liste (canonical-Key-Dedup).
Verlustfrei – kein Empfänger fällt beim Umschalten raus.
Smoke-Test mit echtem Plesk-stdout (User-Log): 3 Group-Members
sauber geparst, leeres "Forward request" als [] erkannt.
Bug: Die Stressfrei-Adresse selbst (max@stressfrei-wechseln.net)
konnte als zusätzliches Weiterleitungsziel eingetragen werden,
auch Plus-Varianten. Plesk leitet auf sich selbst um → Mail-Loop.
Backend setAdditionalForwards: lädt zusätzlich meta.email, vergleicht
canonicalEmailKey gegen canonicalEmailKey(meta.email). Bei Treffer
hartes ApiError(400) mit klarer "zeigt auf die Adresse selbst –
Mail-Loop"-Meldung statt silent dedup – der User soll merken, dass
sein Eintrag bewusst abgelehnt wurde.
Frontend AdditionalForwardsModal: zusätzliche proaktive Validierung
im Sub-Modal mit identischem canonicalize-Helper. Neuer selfEmail-
Prop, damit auch der Create-Modus (vor Persist) den Check fahren
kann. Spart Roundtrip + sofort sprechende Meldung.
Number.isInteger(parseInt('4.5')) ist true, weil parseInt den
Nachkomma-Teil silent verwirft. /.../4.5/... traf die echte ID 4
statt 400 zu liefern – gleiches für 4.0 und Exp-Notation (4e1).
Fix: vor dem Parsen Regex /^\\d+$/ gegen die rohe Route-Eingabe.
Nur reine Ziffern erlaubt, keine Floats / Exp / Vorzeichen /
Whitespace / Hex.
Smoke-Test (17 Cases): 4.0, 4.5, 4e1, 4E2, 0, -4, +4, 0x10, 1.0e0,
leading/trailing Space alle abgelehnt; 1, 4, 100, 9999999
durchgewunken.
Bug: dieselbe E-Mail-Adresse konnte beim selben Kunden mehrfach
angelegt werden – im Screenshot zwei identische Einträge nach
einem Doppel-Submit.
- createEmail: findFirst auf (customerId, email) case-insensitive,
bei Treffer ApiError(409). Eigene Meldung für inaktive
Duplikate (Hinweis: alten Eintrag reaktivieren statt neu anlegen).
- updateEmail: gleicher Check beim Umbenennen, NOT id-Exclude.
- Controller: catch-Blöcke honorieren ApiError.statusCode (vorher
pauschal 400) → 409 kommt sauber an die UI durch.
- Frontend: updateMutation bekam onError, damit der Fehler nicht
schlucken bleibt.
71.1 MEDIUM: BLOCKED_TLDS-Set in assertValidForwardingEmail –
reservierte/private TLDs (local, internal, corp, lan, home,
private, invalid, test, localhost, example, intranet, localdomain,
arpa) werden abgelehnt. Schließt Plesk-DNS-Probing ins interne Netz.
71.2 LOW: canonicalEmailKey-Helper normalisiert Mail-Adressen für
den Dedup (Plus-Tag wegstrippen, lowercase). billing+x@y und
billing@y haben jetzt denselben Schlüssel – auch gegen Kunden-
Stamm-Mail und gegen config.defaultForwardEmail im sync-Pfad.
71.3 INFO: Neuer requireIdParam-Helper im Controller liefert 400
statt 500 bei nicht-numerischen Route-IDs. Alle acht parseInt-
Stellen umgestellt (auch über die gemeldete eine hinaus).
71.4 INFO: setAdditionalForwards rollt den DB-Stand zurück, wenn
syncForwardingForEmail mit dem Provider scheitert. Vorheriger Wert
wird vorm Update gemerkt und im Fehlerfall wieder eingespielt –
DB und Plesk laufen nicht mehr auseinander.
Smoke-Tests: 11 reservierte TLDs abgelehnt, 4 echte TLDs (de, com,
co.uk, museum) durchgewinkt, Plus-Tag-Strip mit Multi-Plus+Casing.
Der "Weitere Weiterleitungen"-Button war bisher nur im Bearbeiten-
Modus sichtbar (provider-vorhanden + ID nötig). Jetzt erscheint er
auch im Anlegen-Modus, sobald "Beim E-Mail-Provider anlegen"
angehakt ist.
- Sub-Modal generalisiert: value/onChange-controlled.
Mit email-Prop → API-Persist pro Änderung (Edit-Modus).
Ohne email-Prop → reiner lokaler State (Create-Modus).
- Haupt-Modal trackt additionalForwards als eigenen State und
ruft nach erfolgreicher createEmail einmalig
updateAdditionalForwards mit der vollen Liste auf – ein zweiter
Provider-Sync mit set: setzt die finale Liste.
- Counter-Badge am Button zeigt die Anzahl bereits eingegebener
Adressen.
Pro StressfreiEmail können jetzt weitere Weiterleitungs-Adressen
gepflegt werden, die zusätzlich zur Stamm-E-Mail des Kunden und
zur globalen Default-Forward-Adresse an den Provider gepusht werden.
- Schema: StressfreiEmail.additionalForwardingEmails (TEXT/JSON-
Array), Migration mit IF NOT EXISTS.
- syncForwardingForEmail liest die Zusatzliste mit und filtert
Duplikate gegen customer.email + config.defaultForwardEmail
(case-insensitive) raus.
- Neuer Endpoint PUT /api/stressfrei-emails/:id/additional-forwards
mit Body { emails: string[] } – ersetzt die Liste komplett und
syncht den Provider direkt nach. Hard-Cap 20 Adressen, Format-
Validation per Regex, Audit-Log.
- Frontend: Button "Weitere Weiterleitungen" im Edit-Modus des
StressfreiEmailModals (erscheint sobald die Adresse beim Provider
vorhanden ist). Sub-Modal mit Liste + Add/Remove, Änderungen
gehen sofort live.
Vier weitere Vorkommen desselben Anti-Patterns wie beim
AddressModal-Fix vom 2026-06-03: setFormData(getInitialFormData())
unbedingt im Render-Body, getriggert durch formData.X !== prop.X.
Jeder Tastendruck setzte den State zurück → kein Editieren möglich.
Fix in MeterModal (meterNumber), BankCardModal (iban),
IdentityDocumentModal (documentNumber), MeterReadingModal (value):
nach useEffect mit [entity?.id]-Dependency umgezogen.
Stage: 2 Handy-JPGs → 23 MB PDF. Smartphone-Fotos haben
4000-6000 px Kante, das macht auch ohne Re-Encode 5-10 MB pro
Bild → PDF wird riesig.
Beim Hinzufügen werden Bilder jetzt auf max 2400 px lange Kante
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
Quality 0.92 (Lightroom-Default) persistiert. Vorschau, Rotation/
Flip und PDF-Embed laufen alle auf dem skalierten Bild.
Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF.
Klick auf die Vertragsnummer öffnet weiterhin im selben Tab
(via React-Router Link). Neues Icon daneben öffnet den Vertrag in
einem neuen Browser-Tab – analog zum Pro-Tab-Link in CustomerDetail.
Tabs-Komponente bekommt optionalen tabHrefBuilder(tabId)-Prop.
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
ExternalLink-Icon, das den Tab via ?tab=<id> in einem neuen
Browser-Tab öffnet.
CustomerDetail übergibt den Builder. URL-Param wird eh schon
für den Tab-Sync genutzt – Anhängen reicht.
Click-stopPropagation verhindert, dass der Klick auf das Icon
gleichzeitig den Tab-Wechsel auslöst.
Viele Vertriebsplattformen vergeben eigene Nummern, die nicht mit
denen des Endanbieters identisch sind. Zwei neue optionale Felder
unter "Anbieter & Tarif".
- Schema: Contract.customerNumberAtSalesPlatform +
contractNumberAtSalesPlatform, Migration mit IF NOT EXISTS.
- ContractForm: zwei neue Inputs direkt unter den entsprechenden
Provider-Feldern.
- ContractDetail: eigene Zeilen mit CopyButton.
- Audit-Log-Mapping + Renewal-Copy + XSS-Strip-Whitelist mitgezogen.
- Bonus: contractNumberAtProvider war im Renewal-Copy und Audit-
Label-Mapping fehlend – mitkorrigiert.
Nach DOMPurify-Sanitize alle <a>-Elemente auf target="_blank" +
rel="noopener noreferrer" setzen. Letzteres verhindert
window.opener-Tab-Hijacking. Sanitize + DOM-Walk in useMemo, läuft
nur bei Wechsel der Email neu.
Stage-Bug: User lädt zwei Handy-JPGs als PDF hoch → 415 mit
"PDF enthält JavaScript-Action". Die JPEG-Bytes im jsPDF-Output
enthielten zufällig die Byte-Folge "/JavaScript" → Pattern-Match
auf Binär-Daten statt PDF-Struktur.
Fix: stream..endstream-Blöcke vor dem Scan rauspatchen. Echte
PDF-Actions stehen IMMER außerhalb von Streams (Object-Dictionaries),
Binär-Streams (Bilder/Fonts/Komprimiertes) werden ignoriert.
Smoke-Test: jspdf-Style-PDF mit /JavaScript-Bytes im Stream
durchgewinkt, echte /OpenAction /S /JavaScript blockiert,
clean PDF OK.
1. AddressModal: Straße-Feld ließ sich nicht editieren. setFormData
wurde im Render-Body aufgerufen, wenn formData.street !==
address.street → Reset bei jedem Tastendruck. In useEffect mit
[address?.id]-Dependency umgezogen.
2. Multer-Limit von 10 MB auf 25 MB in upload.routes.ts,
gdpr.routes.ts, contract.routes.ts. Zwei Handy-Fotos zu PDF
kratzten am alten Limit. FileUpload-Hinweistext angepasst.
Hardware-Plastikkarte vs. eSIM-Profil ist eigene Eigenschaft – eSIM
kann sowohl Hauptkarte als auch Multisim sein, deshalb dritter
Toggle statt entweder/oder.
- Schema: SimCard.isEsim Boolean default false, Migration mit
IF NOT EXISTS.
- Backend: vier SimCard-Schreibpfade in contract.service.ts (Create,
Update, Follow-Up, Renewal).
- UI: dritte Checkbox in ContractForm zwischen Hauptkarte und
Multisim. ContractDetail zeigt blauen eSIM-Badge.
Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
auf (Quality 100 % ≠ "identisch zum Original", sondern "möglichst
viele Bits pro Pixel" – ein schon JPEG-komprimiertes Smartphone-
Foto wird so künstlich 4-8× größer).
Fix 1: Wenn Rotation/Flip unverändert (Standardfall), Original-
DataURL 1:1 in die PDF einbetten – kein Canvas-Roundtrip, keine
Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Format-Detection per
data:image/png-Prefix (PNG vs JPEG).
Fix 2: Bei Transformation toDataURL('image/jpeg', 0.95) statt 1.0.
Visuell identisch für Foto-Inhalte, 50-70 % kleiner.
Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
16-30 MB), 2 gedrehte ≈ 5-8 MB.
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.
contract.routes Vertragsdokumente: Multer-fileFilter blockte
image/gif + image/webp, obwohl validateUploadedFile sie zulässt.
Folge: GIF mit korrektem MIME 415, mit gespooftem MIME 201. Kein
Sicherheitsproblem (Magic-Byte ist der echte Guard), nur Konsistenz.
contract.routes.ts Vertragsdokumente-Upload hatte bisher nur den
PDF-Inhalts-Scan aus 68.1. JPG/PNG-Uploads waren ungeprüft, ohne
canonical Rename – Pentester selbst attestiert "ohne Exploit-Pfad"
(Download-Layer fängt's), aber inkonsistent zu allen anderen
Upload-Pfaden.
- Refactor: detectType + validateUploadedFile aus upload.routes.ts
in neue Middleware uploadFileTypeValidator.ts ausgelagert (Single
Source of Truth, ~90 Zeilen Duplikation entfällt).
- contract.routes.ts: validateUploadedFile ersetzt
scanUploadedPdfIfPresent → Magic-Byte + canonical Rename + PDF-Scan
in einer Pipeline.
- pdfUploadSafety.ts: scanUploadedPdfIfPresent entfernt (tot).
Analog zu address.full/.country: wer im Auftragsformular eine Zeile
"Rechnungsstraße 1, 10115 Berlin" als Single-Slot braucht, kann jetzt
billingAddress.full mappen statt Straße + PLZ + Stadt einzeln. Plus
billingAddress.country für Vollständigkeit.
Beide Slots greifen auf das gleiche bAddr-Resolve (Fallback auf
Lieferadresse) zu, wenn keine separate Rechnungsadresse hinterlegt
ist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wie in der Kundenakte: wenn Contract.billingAddressId NULL ist
(= "Wie Lieferadresse"), liefern die billingAddress.*-Felder im
Auftragsformular jetzt die Werte der Lieferadresse statt leer
zu bleiben.
Konkret betrifft das die 6 Template-Variablen:
- billingAddress.street, houseNumber, streetFull
- billingAddress.postalCode, city, postalCodeCity
Anbieter, die ein vollständig befülltes "Rechnungsadresse"-Block
im PDF erwarten, bekommen es jetzt automatisch – kein manueller
Doppel-Eintrag der Adresse beim Kunden mehr nötig.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24.6 (Portal kann Consent auf PENDING zurücksetzen):
- gdpr.controller updateCustomerConsent prüft jetzt explizit, dass
der Portal-User nur GRANTED oder WITHDRAWN setzen kann. PENDING
ist nur der initiale System-Status; ein Reset darauf hätte die
DSGVO-Auswertung verfälscht.
26.7 (documentPath ohne Validierung):
- Neuer Helper isValidDocumentPath + assertValidDocumentPath in
utils/sanitize: nur /?uploads/<safe>, keine "..", keine
javascript:/data:/vbscript:, kein HTML.
- consent.service.updateConsent ruft den Assert auf – Defense-in-
Depth gegen zukünftige Caller, die documentPath aus User-Input
durchreichen könnten.
- authorization.service.grantAuthorization analog.
- Cleanup-Skript (prisma/cleanup-xss-and-mass-assignment) entfernt
seine lokale Kopie der Path-Validierung und nutzt den shared
Helper – Single Source of Truth.
27.1 (Altdaten in Staging-DB):
- Cleanup-Skript läuft sowieso bei jedem Container-Start. Nina-
Records mit "../../../etc/passwd" werden beim nächsten Restart
genullt (oder verschwinden mit dem VM-Snapshot-Wechsel).
Live-Test isValidDocumentPath: 13/13 OK – legitime Pfade durch,
Traversal/JS-URI/HTML blockiert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assertNoRecentDuplicateDocument warf einen generischen Error → die
Catch-Blöcke in den drei ContractDocument-Schreibpfaden mappten
das auf 500, obwohl es klar eine 400-Class-Situation (Caller-Fehler:
Duplikat-Submit) ist.
Neuer ApiError-Helper in utils/apiError:
- ApiError(statusCode, message) – einfache Subklasse von Error mit
explizitem HTTP-Status.
assertNoRecentDuplicateDocument wirft jetzt ApiError(400, ...).
Catch-Blöcke gehärtet (Service-Pattern: `error instanceof ApiError
? error.statusCode : <default>`):
- contract.controller uploadContractDocument: 400-Default bleibt,
ApiError wird honoriert; bonus: multer-Datei wird bei Reject jetzt
gelöscht (war vorher orphaned bei Lock-Reject).
- cachedEmail.controller saveEmailAsContractDocument: 500-Default,
ApiError → 400.
- cachedEmail.controller saveAttachmentAsContractDocument: dito.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher gingen XSS-Payloads in deliveryDate (saveEmailAsContractDocument,
saveAttachmentAsContractDocument, uploadContractDocument) und
confirmationDate (Cancellation-Confirmation-Upload) mit 200 durch.
Das Datum wurde silent als null behandelt; Impact gering, aber
schlechte API-Hygiene.
Neuer validateOptionalIsoDate-Helper in utils/sanitize:
- ISO-8601-Regex YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS(.fff)?(Z|+HH:MM)?
- null / leerer String / undefined sind OK (Optional-Semantik)
- Sonstige Eingaben werfen 400 mit klarer Meldung
Eingesetzt in:
- contract.controller uploadContractDocument (multer-Datei wird bei
Reject sauber gelöscht)
- cachedEmail.controller saveEmailAsContractDocument +
saveAttachmentAsContractDocument: Validierung früh, BEVOR Dateien
geschrieben werden – kein Datei-Müll bei Reject
- upload.routes handleContractDocumentUpload (cancellationConfirmation*)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Der Fix aus 51.3 deckte nur Contract-PhoneNumber-Felder ab. CRLF in
`Customer.phone`, `Customer.mobile` und (im selben Code-Pfad)
`User.whatsappNumber`, `User.signalNumber` ging weiter durch –
pickCustomerUpdate / pickUserUpdate macht nur stripHtml, das filtert
keine Control-Chars.
- sanitizePhoneField von contract.service nach utils/sanitize gezogen
und EXPORT, damit alle Stellen denselben Allowlist-Check
(/^[0-9+\-/(). ]{0,40}$/) nutzen. Literales Space, NICHT \s.
- customer.controller updateCustomer + createCustomer: phone + mobile
durch sanitizePhoneField → 400 bei CRLF/Control-Chars.
- user.controller updateUser + createUser: whatsappNumber +
signalNumber analog.
- contract.service nutzt jetzt den importierten Helper (Lokale
Kopie entfernt – Single Source of Truth).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node's URL-Parser normalisiert IPv4-mapped IPv6 von Dotted- in
Hex-Form: `::ffff:127.0.0.1` → `::ffff:7f00:1`,
`::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS-Metadata!),
`::ffff:10.0.0.1` → `::ffff:a00:1`.
Die bisherigen Patterns (`::ffff:127\.` etc.) matchten nur die
Dotted-Form. Sobald die URL durch `new URL()` lief, wurde der Host
in Hex-Form herausgereicht und kam an der Blocklist vorbei – live
verifiziert auf test-mail-access mit allen drei Payloads.
Fix in ssrfGuard.ts:
- Neuer extractMappedIPv4-Helper: erkennt Compact-Dotted,
Compact-Hex, Expanded-Dotted, Expanded-Hex – konvertiert auf
Dotted-IPv4.
- Neuer checkIPv4-Helper: läuft die IPv4 durch BLOCKED_PATTERNS
und (optional) PRIVATE_IP_PATTERNS, mit BLOCKED/PRIVATE_HOSTNAMES.
- isBlockedSsrfHost + isPrivateOrBlockedHost rufen den IPv4-Check
bei Mapped-IPv6 zusätzlich auf. Plain IPv4 und Hex-Form werden
damit gleich behandelt.
Verifiziert mit 15-Tests: ::ffff:7f00:1, ::ffff:a9fe:a9fe,
0:0:0:0:0:ffff:7f00:1 etc. werden alle geblockt; legitime IPs
(8.8.8.8, ::ffff:8.8.8.8) bleiben durchlässig.
Nebenbefund (Consent-URL = localhost):
- getPublicUrl in auth.service jetzt EXPORT (vorher private).
- gdpr.controller (sendConsentLink + send-privacy-link) nutzt
jetzt getPublicUrl statt direkt PUBLIC_URL/origin/localhost-
Kette. Damit greift die admin-konfigurierte
AppSetting `portalLoginUrl` auch hier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher lief documentType nur durch stripHtml – ein beliebiger String
("NICHT_ERLAUBT", "DROP TABLE ...", Tippfehler) wurde 1:1 als
ContractDocument.documentType in die DB geschrieben. Das brach
Frontend-Filter, Lieferbestätigung-Auto-Activation und Reports.
Neuer validateContractDocumentType-Helper in utils/sanitize:
- Whitelist ALLOWED_CONTRACT_DOCUMENT_TYPES (8 Werte, gespiegelt aus
Frontend CONTRACT_DOCUMENT_TYPES)
- Case-insensitiver Match, Rückgabe ist immer der kanonische Wert
- Wirft sprechende 400-Fehlermeldung mit Liste der erlaubten Werte
Eingesetzt in allen 3 Schreibpfaden:
- contract.controller.uploadContractDocument (multer-Datei wird bei
Reject sauber gelöscht)
- cachedEmail.controller.saveEmailAsContractDocument
- cachedEmail.controller.saveAttachmentAsContractDocument
Audit-Log + maybeActivateOnDeliveryConfirmation nutzen jetzt den
kanonischen Wert (statt der rohen Eingabe), damit Reports
einheitlich aussehen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57.7 (Consent-Hash ohne TTL):
- Neues Feld Customer.consentHashExpiresAt + Migration
20260601300000_consent_hash_ttl mit IF NOT EXISTS. Bestandsdaten
bekommen NOW()+30d als Default, damit frische Versand-Links nicht
sofort sterben.
- TTL-Konstante CONSENT_HASH_TTL_DAYS = 30 in consent-public.service.
- getCustomerByConsentHash + grantAllConsentsPublic liefern null bzw.
klare Fehlermeldung bei Ablauf; consentHashExpiresAt wird nicht in
der Response durchgereicht (kein Oracle "unbekannt vs. abgelaufen").
- ensureConsentHash erneuert Hash + Frist, sobald der alte abgelaufen
ist – Versand neuer Links bleibt friction-frei.
- consentHashExpiresAt in SENSITIVE_CUSTOMER_FIELDS (sanitize), damit
der Standard-Customer-Endpoint kein Workflow-Info leakt.
57.8 (Zip-Slip / Zip-Bomb):
- Reject zusätzlich: leere Entry-Namen, Backslashes (Cross-OS-
Confusion), Home-Dir-Expansion (`~`), explizite `..`-Segmente
schon im Original-Namen (vor path.resolve).
- Zip-Slip-Check auf path.relative umgestellt – robuster als
startsWith(prefix + sep), insbesondere bei nested Resolution.
- Zip-Bomb-Schutz: 500 MB pro Entry + 5 GB Gesamt-Uncompressed-
Limit; bei Überschreitung Abbruch mit klarer Meldung.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56.1 HIGH (IDOR auf Upload-Endpoints):
- /upload/bank-cards/:id (POST/DELETE): canAccessBankCard +
Existenz-Check, multer-Datei wird bei Reject sauber aufgeräumt.
- /upload/documents/:id (POST/DELETE): canAccessIdentityDocument
+ Existenz-Check + Cleanup.
- /upload/customers/:id/{business-registration,commercial-register,
privacy-policy} (POST/DELETE): canAccessCustomer + Cleanup.
- /upload/invoices/:id (POST/DELETE): canAccessContract über
Invoice→Contract-Resolve + Cleanup.
56.2 HIGH (IDOR + Consent-Eskalation bei privacy-policy):
- Vor dem upsert auf alle 4 CustomerConsent-Einträge (=GRANTED)
läuft jetzt canAccessCustomer. Portal-Vertreter ohne Vollmacht
oder Mitarbeiter mit anderer Customer-Beschränkung kommen
damit nicht mehr durch.
56.3 LATENT (updateContract / deleteContract):
- Defense-in-Depth: canAccessContract jetzt explizit im Controller,
nicht nur über die Route-Permission.
56.4 MEDIUM (invoiceType ungeprüft in addInvoiceByContract):
- Neuer assertValidInvoiceType-Helper mit Whitelist
['INTERIM','FINAL','NOT_AVAILABLE'] in addInvoice,
updateInvoice und addInvoiceByContract. updateInvoice nur
bei explizit gesetztem Wert; addInvoiceByContract zusätzlich
die fehlende Required-Field-Validierung ergänzt.
56.5 LOW (GDPR-Löschanfragen ohne Ownership-Check):
- POST /api/gdpr/deletions liest customerId jetzt aus dem Body
(Route hat kein :id-Segment), validiert auf positive Zahl und
ruft canAccessCustomer auf, bevor die Löschanfrage erstellt wird.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
55.3 HIGH (Contract-Documents ohne Auth abrufbar):
- /uploads/contract-documents/*.pdf war HTTP 200 ohne Token, weil
nginx die Datei direkt ausliefert und Backend nur /api/uploads/*
schützte.
- Defense-in-Depth: app.get('/uploads/*') jetzt ebenfalls mit
authenticate + downloadFile (Ownership-Check) abgesichert.
Falls nginx fehlkonfiguriert sein sollte, fängt das Backend.
55.2 MEDIUM (notes ungestrippt + unlimitiert):
- Neuer sanitizeNotes-Helper: stripHtml + CRLF→LF + Control-Chars
raus + Cap 2000 Zeichen. Eingesetzt für ContractDocument.notes
in allen 3 Schreibpfaden (contract.controller, saveAttachment-
AsContractDocument, saveEmailAsContractDocument).
- documentType zusätzlich stripHtml.
55.4 LOW (Race: 5x Lieferbestätigung → 5 Dokumente):
- Neuer In-Memory-Lock per (contractId, documentType) in
contractStatusScheduler.service. withContractDocumentLock führt
Recent-Duplicate-Check (10s-Window) + Write atomar aus.
- In cachedEmail-Pfaden: fs.writeFileSync ist jetzt INNERHALB des
Locks → kein verwaister Datei-Müll bei Race-Reject.
53.3 (Prisma-Client veraltet bei ungebauten Images):
- docker-entrypoint.sh: `prisma generate` am Container-Start
hinzugefügt. Kostet ~5–10 s, regeneriert den Client gegen das
aktuelle Schema falls jemand ein Stale-Image hochgezogen hat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bei Festnetz/Internet-Verträgen (DSL, FIBER, CABLE) verlangt der
Anbieter beim Auftrag keinen Ausweis – die Cockpit-Warnung
"Ausweis fehlt" war dort nur Rauschen. Mobile bleibt drin, weil
für SIM-Kartenausgabe echte Identitätsfeststellung Pflicht ist.
Die "Ausweis läuft ab"-Warnung bleibt unverändert: sie greift nur,
wenn ein Ausweis verknüpft ist, und ist damit für alle Vertragstypen
sinnvoll (wenn schon ein Ausweis dranhängt, will der User auch
über den Ablauf informiert werden).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher hatte das "E-Mail als PDF speichern"-Modal nur die Tabs
"Als Dokument" + "Als Rechnung" (nur Energieverträge). Wenn die
E-Mail einem Vertrag zugeordnet ist, fehlte die Möglichkeit, sie
direkt als Vertragsdokument (Auftragsformular, Lieferbestätigung
etc.) zu hinterlegen – analog zum Anhang-Modal.
Backend: neuer Endpoint POST /api/emails/:id/save-as-contract-document
{ documentType, notes?, deliveryDate? } – generiert das Mail-PDF,
speichert es unter /uploads/contract-documents und legt einen
ContractDocument-Eintrag an. Bei documentType "Lieferbestätigung"
wird der bestehende maybeActivateOnDeliveryConfirmation-Workflow
getriggert (DRAFT → ACTIVE, startDate-Übernahme).
Frontend: SaveEmailAsPdfModal bekommt den dritten Tab parallel zu
SaveAttachmentModal. Tab erscheint, sobald die E-Mail einem Vertrag
zugeordnet ist (auch bei Nicht-Energieverträgen); Tab "Als Rechnung"
bleibt auf Energieverträge beschränkt. Dokumenttyp-Dropdown und
Notizen-Feld werden aus dem Anhang-Modal übernommen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
51.1 MEDIUM (IPv6-Ranges nicht zuverlässig geblockt):
- URL.hostname liefert IPv6 mit eckigen Klammern ("[::1]") –
safeResolveHost strippt sie jetzt am Eingang, sonst greift
weder net.isIP noch das Regex-Matching.
- PRIVATE_IP_PATTERNS auf Hex-Group-Boundaries gehoben:
/^f[cd][0-9a-f]{2}:/i deckt fc00..fdff zuverlässig ab statt
nur "f[cd]" am String-Anfang.
- Ausgeschriebene IPv6-Formen (0:0:0:0:0:0:0:1, 0:0:0:0:0:ffff:10.x)
als eigene Patterns ergänzt; "[::1]" + "0:0:0:0:0:0:0:1" auch
als BLOCKED_HOSTNAMES.
- fe80: zusätzlich für lange Form (/^fe80:0*:/i).
51.2 LOW (CGNAT + Alibaba Metadata):
- 100.64.0.0/10 (RFC 6598 Carrier-Grade-NAT) → BLOCKED_PATTERNS
- 100.100.100.200 (Alibaba Cloud Metadata) → BLOCKED_HOSTNAMES
51.3 LOW (CRLF in phone-Feldern):
- sanitizePhoneField in contract.service.ts: Allowlist
/^[0-9+\-/(). ]{0,40}$/ – Whitespace bewusst auf literales
Space, NICHT \s, weil \s sonst \r\n\t matched und den
Header-Injection-Schutz aufhebt. Eingesetzt auf phoneNumber
und areaCode in beiden Create-Pfaden und im Update-Pfad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48.1 (XSS in customerEmailLabel):
- Neuer sanitizeCustomerEmailLabel-Helper (stripHtml + trim +
60-Zeichen-Cap)
- Eingesetzt in createProviderConfig + updateProviderConfig
(Write-Pfad) und getProviderPublicSettings (Read-Defensive)
- Damit landet kein <script>/<img onerror>/<svg onload> mehr roh
in der DB, das Längen-Limit ist serverseitig erzwungen, und
Alt-Daten kommen über /public-settings ebenfalls gestrippt raus.
50.1 (SSRF, unvollständige Blockliste bei test-connection):
- safeResolveHost + assertAllowedHost akzeptieren jetzt
{ strict: boolean }. strict=true → isPrivateOrBlockedHost
(sperrt 127/8, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7
unabhängig von SSRF_BLOCK_PRIVATE_IPS).
- test-connection und test-mail-access nutzen strict=true per
Default. Opt-out via env SSRF_ALLOW_INTERNAL_TESTING=true
für On-Prem mit internem Plesk.
- Defense-in-Depth: assertAllowedHost wird jetzt auch VOR der
DNS-Resolution auf den Hostname selbst angewendet, damit
Block-Hostnames (z.B. "metadata.google.internal", "localhost")
nicht via custom-DNS umgangen werden können.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher steht in PhoneNumber.phoneNumber die kombinierte Nummer
("04264 836975"). Die Wechselauftrag-PDFs splittten heuristisch
auf Vorwahl/Anschluss, was bei Sonderformaten daneben ging.
Schema: PhoneNumber.areaCode String? (optional, Bestandsdaten
werden beim nächsten Edit nachgepflegt). Migration
20260601200000_phone_area_code mit IF NOT EXISTS.
ContractForm: aus "Rufnummer" werden zwei Felder – "Vorwahl" und
"Rufnummer". Beim Speichern sendet das Frontend areaCode separat
UND die kombinierte phoneNumber (für Listen/Suchen weiter
unverändert). Beim Edit-Load wird areaCode bevorzugt; falls leer,
splittet die UI heuristisch und prefillt beides – User kann
korrigieren und beim Speichern wird der saubere Wert persistiert.
PDF-Template-Service: phoneAreaCode[N] und phoneLocal[N]
verwenden jetzt primär den gespeicherten areaCode aus der DB
(verlässlich), Heuristik nur als Fallback für Altbestand. Die
Template-Variablen-Liste war bereits korrekt definiert, jetzt
ist die Datenquelle solide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher prüfte der Re-Auth-Trigger nur den Host – `https://1und1.de/foo`
→ `https://1und1.de/phishing/path` ging ohne currentPassword durch.
Damit konnte ein gestohlener JWT Phishing-Pfade auf trusted Domains
plazieren.
Backend (provider.controller): normalizeUrlForCompare vergleicht
jetzt die komplette URL (Trailing-Slash, Whitespace, Case),
nicht nur den Host. hostOf-Helper entfernt.
Frontend (ProviderModal): gleiche Normalisierung im UI, damit der
Bestätigungs-Banner mit der Backend-Prüfung synchron läuft.
Banner-Text leicht angepasst (nicht mehr "Domain wurde geändert"
sondern generisch "Portal-URL wurde geändert").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48.3 (Rate-Limit fehlt): POST /api/users/:id/password verlangt seit
47.3 die Eingabe des eigenen Admin-Passworts. Ohne Throttle könnte
ein Angreifer mit gestohlenem JWT die Re-Auth per Brute-Force
aushebeln.
- Neuer staffPasswordReAuthLimiter (5 Versuche / 10 min,
bucket: IP + target-user-id, skipSuccessfulRequests: true)
- emit SecurityEvent RATE_LIMIT_HIT severity HIGH
- Vor authenticate gemounted, damit auch unauth-Spamming
begrenzt wird
48.4 (Alter Token überlebt Self-Reset): Nach erfolgreichem Setzen
wird tokenInvalidatedAt des Ziel-Users auf jetzt gesetzt. Greift
besonders bei Self-Reset (Admin setzt sich selbst zurück) – ein
zuvor gestohlenes Token wird sofort ungültig, statt bis zum
natürlichen Ablauf (15 min) brauchbar zu bleiben. Die bestehende
Auth-Middleware liest tokenInvalidatedAt bereits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
47.3 MEDIUM (Admin-Passwort-Reset ohne Re-Auth):
POST /api/users/:id/password verlangt jetzt currentPassword im
Body. Backend prüft per bcrypt.compare gegen den Hash des
aufrufenden Admins. Frontend (UserList-Modal): zusätzliches
Passwort-Feld wird eingeblendet, sobald für einen User ein neues
Passwort gesetzt werden soll. Gestohlener JWT allein reicht damit
nicht mehr.
47.1 MEDIUM (Open Redirect / Phishing via provider.portalUrl):
Selbes Re-Auth-Pattern für Provider-Endpoints. Nur wenn die
Portal-URL-Domain WIRKLICH gewechselt wird (Host-Vergleich)
oder beim Create mit URL, ist currentPassword Pflicht. Reine
Namens-/Tarif-Edits bleiben friction-frei.
Audit-Log bekommt die Portal-URL beim Ändern explizit mitgeloggt
(Forensik bei Vorfällen). Frontend ProviderModal zeigt amber-
farbenen Bestätigungs-Banner mit Passwort-Eingabe sobald der
Host wechselt.
47.2 INFO (provider.name ohne Backend-Sanitization):
Neuer Helper stripProviderStrings in provider.service, wendet
stripHtml auf name + usernameFieldName + passwordFieldName an –
Defense-in-Depth gegen neue Renderpfade (PDF, Mail-Templates).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46.1 HIGH (Stored XSS via provider.portalUrl): PUT /api/providers/:id
nahm `javascript:alert(...)` als portalUrl ohne Validierung an, das
Portal rendert es als <a href={portalUrl}> → Klick im Kunden-Browser
löste XSS aus.
Fix: neuer zentraler Helper backend/utils/url.validateHttpUrl
- erlaubt nur http(s)-Schemas (sperrt javascript:, data:, file:,
vbscript:, blob: usw.)
- erfordert absoluten URL mit Host
- per Default keine privaten/Loopback-Hosts (über
isPrivateOrBlockedHost), weil der Wert Endkunden gezeigt wird
- Trailing-Slash wird gestrippt
Eingebaut in:
- provider.service createProvider + updateProvider (HIGH-Fix)
- appSetting.service validateSettingValue für portalLoginUrl
(Refactor der bestehenden ad-hoc Validierung → konsolidiert)
Defense-in-depth Frontend: frontend/utils/url.safeHttpUrl liefert
URLs nur zurück wenn http(s), sonst undefined. Eingesetzt in
ContractDetail bei Portal-Link-Rendering und Auto-Login, damit
Alt-Daten in der DB (vor diesem Fix angelegt) nicht klickbar
bleiben.
INFO-Konsolidierung: damit ist die Schema-/Host-Validierung
einheitlich an einer Stelle. Sanitize-Layer (stripHtml in
sanitize.ts) bleibt für reine Text-Felder zuständig.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SIM-cardUser, EmailProvider-Label-Override etc.)
todo.md: rund 14 neue Erledigt-Einträge seit dem letzten Stand –
gruppiert nach Feature (Folgezähler-Workflow, Anzeige-/UX-Polishing,
Pentest 42.5/43.5/43.6) und im klassischen "kompakter Header +
Bullets"-Stil.
README.md:
- Zähler-Bullet um Lieferadress-Pflicht + Folgezähler-Kette ergänzt
- Strom/Gas-Vertragsfelder um Verbrauchs-Schätzwert aus Vorvertrag,
HT/NT, Sofort-/Neukunden-Bonus, Folgezähler-Wechseldatum + Endstand
- SIM-Karten-Liste um "Kartennutzer" für Firmen-/Familienverträge
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Das customerEmailLabel-Feld existierte im Backend (samt Update-Logik
und Public-Endpoint), war aber im UI nicht erreichbar – das Label
wurde immer nur aus der Domain abgeleitet.
Neuer optionaler Input "Bezeichnung im UI" unter dem Domain-Hinweis.
Leer = automatisch aus Domain ableiten (bisheriges Verhalten),
ausgefüllt = überschreibt die Ableitung (z.B. "interne Kunden
Email Adressen" als Tab-Label).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Der "Stressfrei wechseln Adressen"-Link (sowie "Postfach öffnen")
war nur im Normal-Zweig sichtbar, nicht aber wenn der Kunde noch
gar kein Mailbox-Konto hat. cardTitle in einer gemeinsamen Variable
extrahiert und in beiden Branches verwendet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bei Firmenverträgen (Vertragsinhaber = Firma, Nutzer = Mitarbeiter)
und Familienverträgen (Inhaber = Eltern, Nutzer = Kind) brauchten
wir ein Feld, das den tatsächlichen Nutzer der SIM-Karte erfasst.
Backend: SimCard.cardUser (String?, optional), Migration
20260601100000_sim_card_user mit IF NOT EXISTS. Im Service durch
Create + Update propagiert.
Frontend: Input "Kartennutzer" pro SIM-Karte in ContractForm
(eigene Zeile oberhalb der technischen Felder Rufnummer/SIM-Nr/
PIN/PUK). In ContractDetail wird der Nutzer als "Nutzer: <Name>"
neben den Hauptkarte/Multisim-Badges angezeigt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Die "Anbieter & Tarif"-Card war nur sichtbar, wenn provider oder
tariff gesetzt waren. Bei Entwürfen ohne Anbieter wurden dadurch
auch customerNumberAtProvider + contractNumberAtProvider versteckt,
obwohl sie pflegbar sind und für den Wechsel-Workflow wichtig sind.
Fix: Card-Sichtbarkeitsbedingung um die beiden Felder erweitert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ContractEmailsSection (Vertragsansicht): Zusätzlich zu "Postfach
öffnen" gibt es jetzt "Stressfrei wechseln Adressen" → Tab in der
Kundenakte.
ContractForm (Bearbeiten): Kleine ExternalLink-Icons neben den
Select-Labels:
- Lieferadresse + Rechnungsadresse → Kundenakte/Adressen
- Bankkarte → Kundenakte/Bankkarten
- Ausweis → Kundenakte/Ausweise
- Anbieter + Tarif → Settings/Anbieter & Tarife
- Vertriebsplattform → Settings/Vertriebsplattformen
Select-Komponente nimmt jetzt ReactNode als label (statt nur string),
um JSX-Labels mit eingebettetem Link zu erlauben. Rückwärts-
kompatibel zu allen bestehenden String-Aufrufen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>