/** * 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. */ // 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', // 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; 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]; } 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. */ 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]; } 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):