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>
Label-Klasse war flex -> Block-Layout, das die ganze col-span-2-Zeile
einnimmt. Klicks rechts neben dem Text triggern dann ebenfalls die
Checkbox. Fix: inline-flex – die Label-Box passt sich an den Inhalt
(Checkbox + Text) an.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
43.6 MEDIUM: ContractHistoryEntry.title + .description waren auf
beiden Pfaden ungestrippt – Admin konnte HTML/Script-Tags
einschreiben, Portal-User las sie roh zurück. Fix: stripHtml()
auf Create + Update (Write-Pfad) und sanitizeEntry() im List +
Get (Read-Pfad), damit Alt-Daten ebenfalls clean rausgehen.
43.5 INFO: stripHtml ersetzt javascript: -> blocked: – sinnvoll
bei URL-Feldern, hässlich in Tarif-/Preis-Namen ("blocked:alert(1)"
als Preis). Neuer stripForDisplay-Wrapper entfernt den Marker
zusätzlich in CONTRACT_DISPLAY_STRING_FIELDS + CUSTOMER_DISPLAY_
STRING_FIELDS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Die drei Preisfelder sind im Schema String? (freitextlich für
Angaben wie "0,28 €/kWh"). sanitizeContract strippte sie auf
dem Read-Pfad nicht – damit lieferten Alt-Daten mit XSS-Payloads
("<script>alert(1)</script>") sie 1:1 an die UI aus.
Defense-in-Depth: Write-Pfad hat sanitizeContractBody, das alle
String-Felder rekursiv stripped. Diese Read-Time-Variante
schützt zusätzlich vor Alt-Daten und einem kompromittierten
Admin-Account.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Zusätzlich zum bestehenden Link im Folgezähler-Form bekommt auch
der Card-Header der Strom/Gas-Details einen Link "Zähler verwalten",
der die Zähler-Übersicht des Kunden in einem neuen Tab öffnet –
damit der Link immer sichtbar ist, nicht nur wenn die Folgezähler-
Form aufgeklappt ist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bei Verträgen, die Vorgänger einer Folgevertrags-Kette sind, sind
über ContractMeter auch Folgezähler verknüpft, die nach Vertragsende
installiert wurden. Die Berechnung nahm cm.installedAt..cm.removedAt
1:1 ohne Clamp gegen Contract.startDate/endDate – damit flossen
Zählerstände aus der Folgevertrags-Phase in den Verbrauch dieses
Vertrags ein.
Fix: meterStart = max(installedAt, contractStart),
meterEnd = min(removedAt, contractEnd). Zähler komplett außerhalb
der Laufzeit werden übersprungen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Die Checkbox war falsch implementiert (additiv: zeigt auch Orphans).
Soll laut User filternd wirken: gecheckt = nur Zähler ohne Vertrag.
Logik:
- beide aus: alle aktiven Zähler (Default)
- nur "Inaktive": alle Zähler (aktiv + inaktiv)
- nur "ohne Verträge": aktive Zähler OHNE Vertrag
- beide an: alle Zähler ohne Vertrag (aktiv + inaktiv)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In der Vertragsübersicht tauchen rohe <script>/<img>-Payloads als
Plaintext auf – React escaped sie zwar (kein XSS), sie sehen aber
hässlich aus. Ursprung: Daten aus pre-Pentest-Zeit, bevor
sanitizeContractBody beim Write existierte.
Fix: sanitizeContract und sanitizeCustomer strippen jetzt zusätzlich
HTML in den definierten Display-Feldern (providerName, tariffName,
customerNumberAtProvider, firstName, lastName, companyName, etc.).
Wirkt auch auf nested previousContract + energyDetails.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standardmäßig werden nur Zähler angezeigt, die mindestens einem
Vertrag zugeordnet sind (entweder als Hauptzähler oder über die
Folgezähler-Kette). Mit der neuen Checkbox lassen sich auch
verwaiste Zähler ins Listing holen – nützlich beim Aufräumen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pro Zähler wird jetzt ein "Verträge (N)" Aufklapp-Bereich angezeigt,
der alle Verträge auflistet, die diesen Zähler nutzen – sowohl als
aktueller Hauptzähler (energyDetails.meterId) als auch über die
Folgezähler-Kette (ContractMeter). Dedupliziert auf contractId.
Jeder Eintrag ist Link auf den Vertrag im neuen Tab, mit
Vertragsnummer, Anbieter und Status-Badge. Folgezähler-Ketten-
Einträge werden mit "(über Folgezähler-Kette)" markiert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beide Folgezähler-Forms (Kundenakte MeterModal + Vertragsansicht
SuccessorMeterForm) bekommen eine Checkbox, die standardmäßig
angehakt ist. Beim Speichern wird der Vorgänger automatisch
auf isActive=false gesetzt – ein-klick-fähiger Zählerwechsel.
Backend: createMeter mit successorOf und addSuccessorMeter
akzeptieren deactivatePredecessor (Default true).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Erwähnte Vertragsnummern (Pattern PREFIX-RANDOM) in Title und
Description werden gegen previousContract + followUpContract des
aktuellen Vertrags aufgelöst und als Link mit target="_blank"
gerendert. Nicht aufgelöste Nummern bleiben als Text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ContractForm (Strom/Gas): Wenn ein previousContractId gesetzt ist,
wird der Vorvertrag samt Readings nachgeladen, der Verbrauch
clientseitig berechnet und als "Vorvertrag: X kWh [Übernehmen]"
unter dem Jahresverbrauch-Feld angezeigt. Bei Gas auch unter
"Jahresverbrauch (kWh)".
ContractDetail (Strom/Gas): Wenn annualConsumption leer ist und
ein berechenbarer Vorvertrag existiert, wird "~X kWh, geschätzt
aus Vorvertrag" in der Jahresverbrauch-Zelle angezeigt – damit
der Wert beim Lesen schon als Anhaltspunkt da steht.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wenn im Vertrag kein passender Zähler im Dropdown auftaucht, kann
der User mit einem Klick die Zähler-Übersicht des Kunden in einem
neuen Tab öffnen, um dort einen neuen Zähler anzulegen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher wurde "Letzter Stand alter Zähler" zwar in
ContractMeter.finalReading gespeichert, aber nirgends ausgewertet.
Neuer Helper recordPredecessorFinalReading legt am Wechseldatum
einen regulären MeterReading-Eintrag für den Vorgänger an
(idempotent, mit Validierung gegen vorhandene Stände). Aufgerufen
aus addSuccessorMeter (Vertragsansicht) und createMeter mit
successorOf (Kundenakte).
Folge: Der Endstand erscheint in der Zählerstände-Liste des alten
Zählers und fließt automatisch über calculateMultiMeterConsumption
in den Verbrauch (Zeitraum bis removedAt ist inklusive).
UI-Hinweise in beiden Folgezähler-Forms erklären den Effekt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bei Folgeverträgen / Bestandsverträgen ohne ContractMeter-Eintrag
war der "Folgezähler hinzufügen"-Button unsichtbar, weil er nur
im Multi-Meter-Zweig gerendert wurde.
Zusätzlich im addSuccessorMeter-Backend: bei Single-Meter-Verträgen
wird der bisherige energyDetails.meterId jetzt als ContractMeter
position 0 backfillt und als removed markiert, damit die Kette
lückenlos ist und der alte Zähler im Vertrag dokumentiert bleibt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Meter.predecessorMeterId (Self-Relation) + Migration
20260530140000_meter_predecessor mit IF NOT EXISTS
- createMeter akzeptiert optional successorOf:
{predecessorMeterId, installedAt?, finalReadingPrevious?}.
Vorgänger wird validiert (gleicher Kunde + Typ); alle Verträge
mit dem Vorgänger als aktuellen Zähler werden analog zu
addSuccessorMeter automatisch auf den neuen Zähler umgestellt
(ContractMeter-Eintrag mit removedAt/finalReading für den
Vorgänger, neuer ContractMeter mit installedAt + nächster
Position, energyDetails.meterId aktualisiert)
- MeterModal: Checkbox "Als Folgezähler deklarieren" + Dropdown
Vorgänger + Wechseldatum + Endstand. Typ/Tarifmodell/Adresse
werden vom Vorgänger übernommen und disabled. Info-Banner über
Vertragsauto-Update
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In der Zähler-Zelle der Strom/Gas-Details wird jetzt zusätzlich
"Inaktiv" (rot) neben der Zählernummer angezeigt und der
Standort als kleine Zeile darunter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Meter.addressId (FK → Address, ON DELETE SET NULL) + Migration
20260530100000_meter_address mit IF NOT EXISTS
- Service erzwingt beim Create: Lieferadresse vorhanden + zum
Kunden gehörig + Typ DELIVERY_RESIDENCE
- MeterModal: Pflicht-Dropdown "Lieferadresse"; Save disabled
ohne Adresse; Hinweis-Banner. Bestandszähler ohne Adresse zeigen
"nicht zugeordnet – bitte über Bearbeiten nachpflegen"
- ContractForm: Zähler-Dropdown filtert auf Vertrags-Lieferadresse;
deaktivierte Zähler bleiben sichtbar mit "(deaktiviert)"; bei
Auswahl Toast-Warnung wegen möglichem Altvertrag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neben dem "E-Mails"-Titel der Card im Vertragsdetail jetzt ein
kleiner Link mit ExternalLink-Icon, der den Tab "E-Mail-Postfach"
in der Kundenakte in einem neuen Tab öffnet (target="_blank",
rel="noopener noreferrer"). Greift auf das bereits unterstützte
?tab=emails-Deep-Link-Pattern in CustomerDetail zurück.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upload-Endpoints (/api/upload/...) hatten denselben Mismatch-Vektor
wie schon die Vollmacht-Route (Pentest 28.3): multer prüft nur den
client-gemeldeten MIME-Type, eine `.php`-Datei mit
Content-Type: image/gif rutschte durch und landete als
`<unique>.gif.php` (Doppel-Endung) auf Disk – kein RCE in unserem
Setup, aber dreckige Datei + Inkonsistenz zwischen geliefertem MIME
und tatsächlichem Inhalt.
Fix: neue validateUploadedFile-Middleware nach upload.single(...) –
- liest die ersten 12 Bytes der gerade geschriebenen Datei
- erkennt PDF/PNG/JPEG/GIF/WebP per Magic-Bytes
- bei Mismatch: Datei löschen + 415 "Datei-Inhalt entspricht keinem
zulässigen Typ"
- benennt die Datei auf eine KANONISCHE Endung (.pdf/.jpg/.png/.gif/
.webp) um, abgeleitet aus dem erkannten Typ (NICHT aus
file.originalname). Damit verschwindet `evil.gif.php` zu
`<unique>.gif` (39.4).
- setzt req.file.mimetype auf den erkannten Type, sodass Controller
konsistente Werte sehen.
Eingehängt in allen 10 upload.single('document')-Routes
(bank-cards, documents, business-registrations, commercial-register,
contract-docs etc.).
Live-verifiziert:
- PHP-Datei als image/gif → 415 + Datei gelöscht
- HTML-Datei als application/pdf → 415 + Datei gelöscht
- WebP-Inhalt mit MIME image/png → 200, gespeichert als .webp
- echtes WebP/JPG → 200 mit kanonischer Endung
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pentest 2026-05-30 INFO: Upload-Endpoints lieferten 500 statt
sauberem 4xx, wenn der fileFilter den MIME-Type ablehnte
(z.B. WebP/GIF, die gar nicht in der Allowlist standen) oder
LIMIT_FILE_SIZE getroffen wurde.
Ursache: fileFilter rief cb(new Error(...)) – multer wirft das
weiter, und ohne dedizierten Error-Handler endete es als 500
"Interner Serverfehler" mit Stack-Trace im Log.
Fix:
- WebP + GIF in die Allowlist von upload.routes.ts (Bug-Pen-
test-Erwartung des Reporters).
- Globaler Express-Error-Handler in index.ts unterscheidet jetzt:
* MulterError code=LIMIT_FILE_SIZE → 413 "Datei ist zu groß"
* andere MulterError → 400 "Upload-Fehler: ..."
* Error mit "...erlaubt"-Message → 415 mit Original-Message
* sonst → bisheriger 4xx/500-Pfad
Live-verifiziert:
WebP/GIF/JPG → 200
SVG / text/plain → 415 + klare Message
11 MB PDF → 413 "Datei ist zu groß"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manche Mail-Clients setzen für PDF-Anhänge fälschlich
Content-Type: application/octet-stream (oder application/x-pdf,
"PDF Document" usw.). Der bisherige Whitelist-Check fiel dann
auf Content-Disposition: attachment zurück – der Browser hat
trotz target="_blank" am <a>-Tag KEINEN neuen Tab geöffnet,
sondern die Datei direkt im aktuellen Tab "geöffnet" (Download
oder native PDF-Anzeige), je nach Browser-Konfiguration. Effekt
für den User: Klick auf Vorschau-Icon → Vorschau ersetzt das
CRM-UI.
Fix: Magic-Byte-Detection direkt am Buffer (gleiche Logik wie
beim /api/files/download-Endpoint). PDF/PNG/JPEG/GIF/WebP werden
zuverlässig erkannt, der vom IMAP gemeldete Type wird ignoriert
(real-world unzuverlässig). Bei Match → inline mit erkanntem
Type; sonst attachment + octet-stream. text/plain bleibt durch
einen schwächeren Sniff-Check zugelassen, sofern keine HTML-
Tags am Anfang stehen.
Stored-XSS-Schutz unverändert: HTML-Anhang mit .pdf-Endung →
kein PDF-Magic → kein inline → attachment + octet-stream → kein
Browser-Rendern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folge-Symptom des Pen-30.13-Fixes: alle file-downloads liefen mit
Content-Disposition: attachment – das ist gegen Stored-XSS richtig,
hat aber die "Anzeigen"-Buttons (Bankkarten / Ausweise /
Verträge / etc.) kaputtgemacht, weil der Browser jetzt
herunterlud statt im Tab zu öffnen.
Magic-Byte-basierter Whitelist-Pfad eingebaut: optional ?disposition=
inline am Download-Endpoint, ABER nur wenn die ersten Bytes der
Datei das Magic eines safe Typs zeigen (PDF, PNG, JPEG, GIF, WebP).
Bei Mismatch fällt's auf attachment zurück – Stored-XSS bleibt
weiterhin unmöglich, falls jemand HTML als .pdf hochlädt.
Frontend: neuer viewUrl(path)-Alias = fileUrl(path, {inline: true}).
Alle Stellen mit `<a href={fileUrl(...)} target="_blank">` oder
`window.open(fileUrl(...), '_blank')` (13 Stellen über CustomerDetail,
ContractDetail, PdfTemplates, GDPRDashboard, InvoicesSection)
nutzen jetzt viewUrl. Download-Stellen bleiben fileUrl
(= attachment, byte-genaues File-Save).
Live-verifiziert auf dev:
- ohne Param: attachment (default, Stored-XSS-Schutz)
- ?disposition=inline + echte PDF: inline + application/pdf
- ?disposition=inline + HTML als .pdf: attachment (Magic-Mismatch
→ Browser lädt herunter statt zu rendern)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>