/** * Sanitize-Helpers: entfernen sensible Felder aus DB-Ergebnissen, bevor sie * als API-Response rausgehen. Zentrale Stelle, damit keine Passwort-Hashes, * Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken. */ import { ApiError } from './apiError.js'; // Felder die NIE in einer API-Response an den Client gehen dürfen const SENSITIVE_CUSTOMER_FIELDS = [ 'portalPasswordHash', 'portalPasswordResetToken', 'portalPasswordResetExpiresAt', // consentHash ist ein Pseudo-Credential für den öffentlichen Consent-Link // (jeder mit dem Hash kann Einwilligungen erteilen + Name/Kundennummer // anzeigen). Über GET /customers/:id darf es nicht raus. Wer ihn legitim // braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener // Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16). 'consentHash', // Pentest 57.7 (2026-06-01): TTL des Public-Consent-Links – kein Leak // an die Standard-Customer-Response, da Existenz/Ablaufzeit Info über // den Workflow gibt. 'consentHashExpiresAt', // Session-/OTP-State – Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt // einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und // wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne // Auth-Bypass, aber unnötig. Wenn Admin diese Information legitim // braucht (z.B. UI-Hinweis "OTP wurde noch nicht eingelöst"), führen // wir bei Bedarf einen eigenen Endpoint ein. 'portalPasswordMustChange', 'portalTokenInvalidatedAt', ] as const; // Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen // sollen – Interne Session-/Workflow-State, kein direkter Auth-Bypass, aber // unnötige Informationsleckage über den DB-Aufbau. // Pentest Runde 7 (2026-05-17), MEDIUM. const PORTAL_HIDDEN_CUSTOMER_FIELDS = [ // portalTokenInvalidatedAt + portalPasswordMustChange sind jetzt in // SENSITIVE_CUSTOMER_FIELDS (immer raus), nicht mehr nur für Portal. 'portalLastLogin', 'lastBirthdayGreetingYear', // privacyPolicyPath etc. sind interne Datei-Pfade – Portal nutzt // dedizierte PDF-Endpoints, nicht den Pfad direkt 'privacyPolicyPath', 'businessRegistrationPath', 'commercialRegisterPath', // Pentest Runde 10 (2026-05-17): notes sind interne CRM-Vermerke // ("Kunde ist schwierig" etc.) und gehören nicht in die Portal-Sicht. 'notes', ] as const; // Felder die im Contract NIE rausgehen dürfen (auch nicht an Mitarbeiter). // portalPasswordEncrypted ist nur über den dedizierten /password-Endpoint // (mit Audit-Log) abrufbar – im /contracts/:id selbst nutzlos. const SENSITIVE_CONTRACT_FIELDS = [ 'portalPasswordEncrypted', ] as const; // Zusätzliche Felder die Portal-User nicht sehen sollen (interne CRM-Daten). // Pentest Runde 7 (2026-05-17): commission + notes leakten an Portal-User. const PORTAL_HIDDEN_CONTRACT_FIELDS = [ 'commission', 'notes', 'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature ] as const; // User-eingabe String-Felder am Contract, die in der UI dargestellt werden. // Werden beim Read über stripHtml geschickt, damit Alt-Daten mit rohen // XSS-Payloads (vor Einführung von sanitizeContractBody) nicht mehr als // `` in der Liste auftauchen. Neue Daten sind // schon beim Write gestrippt, aber doppelt hält besser. const CONTRACT_DISPLAY_STRING_FIELDS = [ 'providerName', 'tariffName', 'customerNumberAtProvider', 'contractNumberAtProvider', 'orderNumberAtSalesPlatform', 'customerNumberAtSalesPlatform', 'contractNumberAtSalesPlatform', 'portalUsername', 'previousProviderName', 'previousCustomerNumber', 'previousContractNumber', 'notes', // Preisfelder sind im Schema `String?` (freitextlich, nicht numerisch), // damit Tarifangaben wie "0,28 €/kWh" oder "27,90 € + 10 € Bonus" // möglich sind. Pentest 2026-05-30 (MEDIUM, 42.5): rohe HTML-Payloads // in den drei Feldern überlebten den Write-Strip nicht und kommen // beim Read 1:1 wieder raus. 'priceFirst12Months', 'priceFrom13Months', 'priceAfter24Months', ] as const; // User-eingabe String-Felder am Customer für dieselbe Read-Time-Defensive. const CUSTOMER_DISPLAY_STRING_FIELDS = [ 'firstName', 'lastName', 'companyName', 'salutation', 'email', 'phone', 'mobile', 'portalEmail', 'portalUsername', 'taxNumber', 'commercialRegisterNumber', 'notes', ] as const; const SENSITIVE_USER_FIELDS = [ 'password', 'passwordResetToken', 'passwordResetExpiresAt', 'tokenInvalidatedAt', ] as const; /** * Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt. * `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat * (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt * es standardmäßig. Embedded `contracts[]` werden ebenfalls sanitisiert * (Pentest Runde 10 – DTO-Leak in eingebetteten Objekten). */ export function sanitizeCustomer>(customer: T | null): T | null { if (!customer) return customer; const copy: Record = { ...customer }; for (const field of SENSITIVE_CUSTOMER_FIELDS) { delete copy[field]; } for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) { if (typeof copy[field] === 'string') { copy[field] = stripForDisplay(copy[field]); } } if (Array.isArray(copy.contracts)) { copy.contracts = (copy.contracts as Record[]).map((c) => sanitizeContract(c)); } // portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das // Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission // auf 'customers:update' implizit gesichert. return copy as T; } /** * Entfernt portalPasswordEncrypted + portal-interne Workflow-Felder zusätzlich * zu den allgemein sensiblen Feldern. Für Kontexte in denen der Caller KEIN * Admin ist (z.B. Portal-Kunde). Embedded `contracts[]` werden mit der * Strict-Variante sanitisiert. */ export function sanitizeCustomerStrict>(customer: T | null): T | null { if (!customer) return customer; const copy = sanitizeCustomer(customer) as Record | null; if (!copy) return null; delete copy.portalPasswordEncrypted; for (const field of PORTAL_HIDDEN_CUSTOMER_FIELDS) { delete copy[field]; } if (Array.isArray(copy.contracts)) { copy.contracts = (copy.contracts as Record[]).map((c) => sanitizeContractStrict(c)); } return copy as T; } /** * Sanitize-Liste von Customers. */ export function sanitizeCustomers>(customers: T[]): T[] { return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null); } /** * Sanitize Contract-Objekt für alle Caller. Entfernt das verschlüsselte * Provider-Passwort (nur über den dedizierten /password-Endpoint mit * Audit-Log abrufbar) und sanitisiert das embedded customer. */ // Sanitisierung für freitextliche User-Notizen (ContractDocument.notes, // Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM, // 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen // roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection- // und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt. // - Tags + gefährliche Schemata via stripHtml // - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren // - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen) // Pentest 58.1 (MEDIUM, 2026-06-01): documentType wurde nur durch // stripHtml geschickt, aber NICHT gegen eine Whitelist geprüft. Damit // landeten beliebige Strings (`NICHT_ERLAUBT`, `DROP TABLE …`, // Tippfehler-Werte aus alten UI-Versionen) als documentType in der // ContractDocument-Tabelle und brachen Frontend-Filter, Auto-Activation // (Lieferbestätigung-Trigger) und Reports. // // Whitelist spiegelt die Konstante CONTRACT_DOCUMENT_TYPES aus // SaveAttachmentModal / SaveEmailAsPdfModal im Frontend. Beide // Listen MÜSSEN synchron gehalten werden – idealerweise später // in eine geteilte Konfiguration gehoben. export const ALLOWED_CONTRACT_DOCUMENT_TYPES = [ 'Auftragsformular', 'Auftragsbestätigung', 'Lieferbestätigung', 'Vertragsunterlagen', 'Vollmacht', 'Widerrufsbelehrung', 'Preisblatt', 'Sonstiges', ] as const; const CONTRACT_DOCUMENT_TYPE_SET: Set = new Set(ALLOWED_CONTRACT_DOCUMENT_TYPES); /** * Validiert + normalisiert einen documentType-Wert. Wirft einen Fehler * mit klarer Liste, wenn der Wert nicht in der Whitelist steht (der * aufrufende Controller mappt das auf 400). Trimmt Whitespace und macht * den Vergleich case-insensitive – damit `"lieferbestätigung"` aus * Drittsystemen sauber matched, aber `"Lieferbestätigung_DROP"` rausfliegt. */ export function validateContractDocumentType(raw: unknown): string { if (typeof raw !== 'string') { throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`); } const cleaned = stripHtml(raw) as string; const trimmed = cleaned.trim(); if (trimmed === '') { throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`); } const canonical = ALLOWED_CONTRACT_DOCUMENT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase()); if (!canonical) { throw new Error(`Ungültiger documentType '${trimmed}'. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`); } return canonical; } // Pentest 26.7 LOW (defense-in-depth, 2026-06-02): documentPath wird // (außer beim Upload-Endpoint) NIE direkt aus User-Input übernommen. // Falls doch jemand auf die Idee kommt, das Feld irgendwo zu mappen, // fangen wir hier Path-Traversal / Javascript-URIs / HTML ab. // Spiegelt isValidDocumentPath aus prisma/cleanup-xss-and-mass-assignment.ts // 1:1 – Single Source of Truth für Lese- UND Schreibpfad. export function isValidDocumentPath(v: string | null | undefined): boolean { if (!v) return true; // null/leer ist OK – Feld bleibt einfach unbesetzt if (typeof v !== 'string') return false; if (v.includes('..')) return false; if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false; if (/<[a-z!/]/i.test(v)) return false; return /^\/?uploads\/[A-Za-z0-9._\-/]+$/.test(v); } export function assertValidDocumentPath(v: string | null | undefined, fieldLabel = 'documentPath'): void { if (!isValidDocumentPath(v)) { throw new Error(`${fieldLabel} ist kein gültiger Upload-Pfad (erlaubt: /uploads/).`); } } // Pentest 68.1 (LOW, 2026-06-03): PDFs mit JavaScript, /Launch (externes // Programm), /EmbeddedFile (eingebettete Executables) oder /RichMedia // (Flash) kamen durch den reinen Magic-Byte-Check (%PDF-) und wurden // inline ausgeliefert. Browser-PDF-Viewer (PDFium/PDF.js) führen kein JS // aus, aber sobald jemand die PDF in Adobe Acrobat öffnet, läuft sie. // → Wir blocken das schon beim Upload. // // PDF-Name-Objekte sind laut PDF 32000-1:2008 §7.3.5 case-sensitive, also // kein /i auf den Patterns. Whitespace nach `/` ist im Standard zwar // erlaubt, in real-world Exploits aber praktisch nie zu sehen – wir // bleiben hier pragmatisch. // // Hinweis: erkannt wird nur, was im Klartext im PDF-Body steht. // Komprimierte oder verschlüsselte Streams entgehen dem String-Scan. // Für unser Bedrohungsmodell (kompromittierter Staff-Account, LOW) reicht // das – ein vollständiger PDF-Parser wäre Overkill. const PDF_DANGER_PATTERNS: { pattern: RegExp; label: string }[] = [ { pattern: /\/JavaScript\b/, label: 'JavaScript-Action' }, { pattern: /\/JS\b/, label: 'JavaScript-Action' }, { pattern: /\/Launch\b/, label: 'Launch-Action (externes Programm)' }, { pattern: /\/EmbeddedFile\b/, label: 'eingebettete Datei' }, { pattern: /\/RichMedia\b/, label: 'RichMedia-Inhalt (Flash)' }, ]; export function assertSafePdf(buf: Buffer): void { if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') { return; // keine PDF → andere Validatoren zuständig } // Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen. // Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive // bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug // 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF- // Object-Stream (außerhalb von `stream..endstream`-Blöcken). const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, ''); for (const { pattern, label } of PDF_DANGER_PATTERNS) { if (pattern.test(scanTarget)) { throw new ApiError( 415, `PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`, ); } } } // Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder // dürfen NIE CRLF oder andere Control-Chars enthalten – sonst sind sie // ein Header-Injection-Vektor (Mail, HTTP), wenn der Wert mal in einen // Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf // literales Space beschränkt, NICHT `\s` – das matched sonst `\r\n\t`. // Allowed: Ziffern, Plus, Minus, Slash, Klammern, Punkt, Space. Bis 40 Zeichen. // // 51.3 deckte nur Contract-Phone-Felder ab; 60.3: `Customer.phone` / // `Customer.mobile` waren immer noch offen, weil pickCustomerUpdate nur // stripHtml laufen ließ – das filtert keine Control-Chars. const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/; export function sanitizePhoneField(raw: unknown, fieldLabel: string): string | undefined { if (raw == null) return undefined; const trimmed = String(raw).trim(); if (trimmed === '') return undefined; if (!PHONE_FIELD_ALLOWED.test(trimmed)) { throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`); } return trimmed; } // Pentest 62.7 (LOW, 2026-06-02): deliveryDate/confirmationDate-Felder // liefen ungeprüft in maybeActivateOnDeliveryConfirmation. XSS-Payloads // gingen mit 200 durch, weil das ungültige Datum nur silent als null // behandelt wurde. Impact gering, aber API-Hygiene: ungültige Eingabe // soll 400 zurückgeben, nicht 200. // // Akzeptiert: ISO-8601-Datum (YYYY-MM-DD) und Datum+Zeit (mit oder ohne // Zeitzone). Whitespace wird getrimmt. null / leerer String / undefined // sind OK – der Aufrufer behandelt das als "Datum nicht gesetzt". const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/; export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): string | null { if (raw == null) return null; if (typeof raw !== 'string') { throw new Error(`${fieldLabel} muss ein Datums-String (YYYY-MM-DD) sein.`); } const trimmed = raw.trim(); if (trimmed === '') return null; if (!ISO_DATE_REGEX.test(trimmed)) { throw new Error(`${fieldLabel} muss ISO-8601-Format haben (YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS).`); } const parsed = new Date(trimmed); if (isNaN(parsed.getTime())) { throw new Error(`${fieldLabel} ist kein gültiges Datum.`); } return trimmed; } // Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags- // Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder // Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen // 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem // überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)` // die stripHtml-Defense (kein umschließender Tag → kein Match), die // in PDF-/Mail-Export potentiell aktiv werden könnten. // // Whitelist orientiert sich am Vorschlag des Pentesters // `^[\w\-\/\s\.]*$` – Whitespace ist hier bewusst NUR ein literales // Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor // in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale // Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)- // Limit der DB-Spalte. const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet = new Set([ 'customerNumberAtProvider', 'contractNumberAtProvider', 'orderNumberAtSalesPlatform', 'customerNumberAtSalesPlatform', 'contractNumberAtSalesPlatform', ]); const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/; const CONTRACT_IDENTIFIER_MAX_LEN = 100; export function isContractIdentifierField(key: string): boolean { return CONTRACT_IDENTIFIER_FIELDS.has(key); } export function validateContractIdentifier( raw: unknown, fieldLabel: string, ): string | null { if (raw == null) return null; if (typeof raw !== 'string') { throw new ApiError(400, `${fieldLabel} muss ein Text sein.`); } const trimmed = raw.trim(); if (trimmed === '') return null; if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) { throw new ApiError( 400, `${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`, ); } if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) { throw new ApiError( 400, `${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`, ); } return trimmed; } // Pentest 95.1/95.3/95.4 (MEDIUM/LOW, 2026-06-21): Manuelles // `portalUsername` am Vertrag hatte gar keine Validierung. Drei // nachweisbare Effekte: // - `foo\r\nBcc:evil@x.de` (CRLF) verbatim gespeichert → // Header-Injection-Vektor sobald der Wert in Mail-Templates // oder PDF-Footers landet. // - `@x.de` lief durch stripHtml → // stille Mutation (R87.1/R89.2-Pattern auf neuem Feld). // - >190 Zeichen → VARCHAR-Overflow → generischer 500 statt 400. // // Bewusst NICHT übernommen wurde R95.2 (Email-Format-Pflicht): // `portalUsername` ist im Manual-Modus nicht zwingend eine // E-Mail. Vodafone, 1&1, EWE und etliche Stadtwerke nutzen // Kundennummern, Pseudonyme oder Customer-IDs als Portal-Login. // Eine `email().regex()`-Pflicht würde legitime Logins ablehnen. // Der Stressfrei-Modus hängt eh an einer schon validierten // Email-Stammdate (assertValidForwardingEmail). // // Allowed: Alphanumerisch + `_`, `-`, `.`, `/`, `@`, `+`, Space. // Damit sind Vodafone-Kunden-IDs (`12345678`), Pseudonyme // (`max.mustermann`), Plus-Tag-Emails (`m+tag@example.com`) // und gemischte Formen abgedeckt. Strukturell sind CRLF, Tab, // alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne // extra Check. Max 100 Zeichen << VARCHAR(191) → R95.4. const PORTAL_USERNAME_ALLOWED = /^[A-Za-z0-9_\-/.@+ ]{0,100}$/; const PORTAL_USERNAME_MAX_LEN = 100; export function validatePortalUsername( raw: unknown, fieldLabel = 'portalUsername', ): string | null { if (raw == null) return null; if (typeof raw !== 'string') { throw new ApiError(400, `${fieldLabel} muss ein Text sein.`); } const trimmed = raw.trim(); if (trimmed === '') return null; if (trimmed.length > PORTAL_USERNAME_MAX_LEN) { throw new ApiError( 400, `${fieldLabel} darf maximal ${PORTAL_USERNAME_MAX_LEN} Zeichen lang sein.`, ); } if (!PORTAL_USERNAME_ALLOWED.test(trimmed)) { throw new ApiError( 400, `${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, @, +, Leerzeichen).`, ); } return trimmed; } // Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am // Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes // hat das Length-Cap silent durchgeschoben (slice statt Error) und // stripHtml lief vor dem Length-Check – derselbe Fehler wie R87: // `` reduziert auf leeren String → null in der // DB → vorheriger Wert ohne Fehlermeldung überschrieben. // // Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation. // - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80) // - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML) // - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc) // - leerer/getrimmt-leerer Input → null (Feld zurücksetzen) // - CRLF → LF normalisieren const PROVIDER_ADDRESS_MAX_LEN = 500; // Erlaubt: nur LF (`\x0A`) als Newline. Alles andere – inkl. Tab (`\x09`) – // fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail // und nichts, was ein Mensch je tippt. const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/; export function validateProviderAddress( raw: unknown, fieldLabel: string, ): string | null { if (raw == null) return null; if (typeof raw !== 'string') { throw new ApiError(400, `${fieldLabel} muss ein Text sein.`); } // CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen – ein Editor der // immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei // Zeichen gegen das 500er-Cap zählen. const normalized = raw.replace(/\r\n?/g, '\n'); if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) { throw new ApiError( 400, `${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`, ); } if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) { throw new ApiError( 400, `${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`, ); } const trimmed = normalized.trim(); return trimmed === '' ? null : trimmed; } const NOTES_DEFAULT_MAX = 2000; export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null { if (raw == null) return null; if (typeof raw !== 'string') return null; const stripped = stripHtml(raw) as string; // CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n // herausfiltern. Null/Form-Feed/Tabs raus. const normalized = stripped .replace(/\r\n?/g, '\n') .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); const trimmed = normalized.trim(); if (trimmed === '') return null; return trimmed.slice(0, maxLength); } // Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen // Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht, // dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen // oder Preisfeld ist er nur kosmetischer Müll. // Pentest 2026-05-30 (INFO, 43.5): `javascript:alert(1)` in // priceFirst12Months wurde als "blocked:alert(1)" angezeigt. function stripForDisplay(value: unknown): unknown { const stripped = stripHtml(value); if (typeof stripped === 'string' && stripped.includes('blocked:')) { return stripped.replace(/blocked:/g, '').trim(); } return stripped; } export function sanitizeContract>(contract: T | null): T | null { if (!contract) return contract; const copy: Record = { ...contract }; for (const field of SENSITIVE_CONTRACT_FIELDS) { delete copy[field]; } for (const field of CONTRACT_DISPLAY_STRING_FIELDS) { if (typeof copy[field] === 'string') { copy[field] = stripForDisplay(copy[field]); } } // Nested: previousProviderName liegt im energyDetails-Sub-Objekt if (copy.energyDetails && typeof copy.energyDetails === 'object') { const ed = copy.energyDetails as Record; if (typeof ed.previousProviderName === 'string') { ed.previousProviderName = stripHtml(ed.previousProviderName); } if (typeof ed.previousCustomerNumber === 'string') { ed.previousCustomerNumber = stripHtml(ed.previousCustomerNumber); } } // Nested: previousContract wird rekursiv auch sanitisiert if (copy.previousContract && typeof copy.previousContract === 'object') { copy.previousContract = sanitizeContract(copy.previousContract as Record); } if (copy.customer && typeof copy.customer === 'object') { copy.customer = sanitizeCustomer(copy.customer as Record); } return copy as T; } /** * Sanitize Contract für Portal-User: zusätzlich werden interne CRM-Felder * (Provision, Notizen, Snooze-Date) gestrippt und das embedded customer * mit `sanitizeCustomerStrict` gefiltert. Pentest Runde 7 (2026-05-17). */ export function sanitizeContractStrict>(contract: T | null): T | null { if (!contract) return contract; const copy = sanitizeContract(contract) as Record | null; if (!copy) return null; for (const field of PORTAL_HIDDEN_CONTRACT_FIELDS) { delete copy[field]; } if (copy.customer && typeof copy.customer === 'object') { copy.customer = sanitizeCustomerStrict(copy.customer as Record); } return copy as T; } export function sanitizeContracts>(contracts: T[]): T[] { return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null); } export function sanitizeContractsStrict>(contracts: T[]): T[] { return contracts.map((c) => sanitizeContractStrict(c)).filter((c): c is T => c !== null); } /** * Sanitize User-Objekt für API-Responses. */ export function sanitizeUser>(user: T | null): T | null { if (!user) return user; const copy = { ...user }; for (const field of SENSITIVE_USER_FIELDS) { delete copy[field]; } return copy; } // ==================== REQUEST-BODY WHITELISTS ==================== // Gegen Mass-Assignment: Nur explizit erlaubte Felder aus req.body übernehmen. const CUSTOMER_UPDATABLE_FIELDS = [ 'type', 'salutation', 'useInformalAddress', 'firstName', 'lastName', 'companyName', 'foundingDate', 'birthDate', 'birthPlace', 'email', 'phone', 'mobile', 'taxNumber', 'commercialRegisterNumber', 'notes', 'portalEnabled', 'portalEmail', 'autoBirthdayGreeting', 'autoBirthdayChannel', // Nicht: portalPasswordHash, portalPasswordEncrypted, portalPasswordResetToken, // portalTokenInvalidatedAt, customerNumber, id, createdAt, updatedAt, consentHash, // lastBirthdayGreetingYear, privacyPolicyPath, businessRegistrationPath, commercialRegisterPath ] as const; const CUSTOMER_CREATE_FIELDS = [ ...CUSTOMER_UPDATABLE_FIELDS, // customerNumber wird vom Service generiert – nicht aus req.body übernehmen ] as const; const USER_UPDATABLE_FIELDS = [ 'email', 'firstName', 'lastName', 'isActive', 'whatsappNumber', 'telegramUsername', 'signalNumber', 'roleIds', // hasGdprAccess + hasDeveloperAccess sind keine User-Spalten – der Service // mappt sie auf die versteckten Rollen DSGVO/Developer (siehe // setUserGdprAccess / setUserDeveloperAccess). Müssen aber auf der Whitelist // stehen, damit pick() sie nicht aus dem Request entfernt. 'hasGdprAccess', 'hasDeveloperAccess', // Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt // Nicht: password – wird über dedizierten Endpoint POST /users/:id/password // gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update // hatte password in der Whitelist, ein Admin konnte stillschweigend ohne // dedizierten Audit-Trail Passwörter überschreiben). ] as const; // Bei CREATE braucht's das initial-Passwort const USER_CREATE_FIELDS = [ ...USER_UPDATABLE_FIELDS, 'password', ] as const; /** * Strippt HTML-Tags und Script-/Style-Inhalt aus einem String, damit ein * gespeicherter Wert nicht später irgendwo zum aktiven XSS-Vektor wird * (z.B. PDF-Generator, E-Mail-Template oder ein dangerouslySetInnerHTML * im Frontend). React-Auto-Escaping fängt den normalen Fall ab, aber * Defense-in-Depth speichert lieber gleich nichts Bösartiges. * * Verlauf: * - Pentest Runde 11 (2026-05-18):