adc3b70492
MEDIUM – Consent-Mass-Assignment:
PUT /api/gdpr/customer/:id/consents/:type nahm source/documentPath/
version ungefiltert aus dem Body. Portal-User konnte
source="ADMIN_OVERRIDE", version="<script>" oder
documentPath="../../etc/passwd" durchschmuggeln.
Fix: nur status aus Body, source server-seitig auf "portal"
hardcoded, documentPath/version bleiben NULL (werden dediziert
vom Authorization-Upload server-seitig gesetzt). Whitelist
ALLOWED_CONSENT_SOURCES für source-Werte. grantAuthorization
(Admin) erzwingt die Whitelist ebenfalls; notes läuft jetzt
durch stripHtml.
LOW – javascript:-URI in companyName:
stripHtml() entfernte HTML-Tags, ließ aber javascript:/data:/
vbscript:-Schemata stehen. companyName="javascript:alert(1)"
hätte in <a href={companyName}> aktiv werden können.
Fix: stripHtml ersetzt jene Schemata mit "blocked:" – legitimer
Text bleibt unangetastet, das Schema wird unschädlich.
LOW – documentPath ohne Validierung:
Bereits durch obigen Consent-Fix erledigt; Cleanup-Pass strippt
zusätzlich vorhandene dreckige Pfade.
cleanup-xss-and-mass-assignment.ts: neue cleanupConsents() läuft
beim Container-Start, normalisiert source per Whitelist auf
"unknown" + stripHtml über version/documentPath.
Live-verifiziert auf dev (alle drei Payloads geblockt + Cleanup
auf dirty DB greift).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
/**
|
||
* 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<T extends Record<string, unknown>>(customer: T | null): T | null {
|
||
if (!customer) return customer;
|
||
const copy: Record<string, unknown> = { ...customer };
|
||
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
|
||
delete copy[field];
|
||
}
|
||
if (Array.isArray(copy.contracts)) {
|
||
copy.contracts = (copy.contracts as Record<string, unknown>[]).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<T extends Record<string, unknown>>(customer: T | null): T | null {
|
||
if (!customer) return customer;
|
||
const copy = sanitizeCustomer(customer) as Record<string, unknown> | 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<string, unknown>[]).map((c) => sanitizeContractStrict(c));
|
||
}
|
||
return copy as T;
|
||
}
|
||
|
||
/**
|
||
* Sanitize-Liste von Customers.
|
||
*/
|
||
export function sanitizeCustomers<T extends Record<string, unknown>>(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<T extends Record<string, unknown>>(contract: T | null): T | null {
|
||
if (!contract) return contract;
|
||
const copy: Record<string, unknown> = { ...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<string, unknown>);
|
||
}
|
||
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<T extends Record<string, unknown>>(contract: T | null): T | null {
|
||
if (!contract) return contract;
|
||
const copy = sanitizeContract(contract) as Record<string, unknown> | 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<string, unknown>);
|
||
}
|
||
return copy as T;
|
||
}
|
||
|
||
export function sanitizeContracts<T extends Record<string, unknown>>(contracts: T[]): T[] {
|
||
return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null);
|
||
}
|
||
|
||
export function sanitizeContractsStrict<T extends Record<string, unknown>>(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<T extends Record<string, unknown>>(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.
|
||
* Pentest Runde 11 (2026-05-18), M2: <script>alert(1)</script> in
|
||
* companyName landete vorher ungefiltert in der DB.
|
||
*
|
||
* Pentest 2026-05-20 (LOW): zusätzlich werden Skript-URI-Schemata
|
||
* unschädlich gemacht (`javascript:`, `data:`, `vbscript:`). Plain-Text-
|
||
* Felder enthalten legitime URLs ohnehin selten; ein gespeicherter
|
||
* `javascript:alert(1)` würde ansonsten in einem `<a href={value}>`
|
||
* sofort feuern.
|
||
*/
|
||
const DANGEROUS_URI_SCHEMES = /(?:javascript|data|vbscript)\s*:/gi;
|
||
|
||
export function stripHtml(value: unknown): unknown {
|
||
if (typeof value !== 'string') return value;
|
||
return value
|
||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||
// Schema durch harmloses Token ersetzen – komplette Entfernung
|
||
// könnte legitimen Text wie "Java Script :)" verändern, dieses
|
||
// Pattern matcht nur das Schema selbst.
|
||
.replace(DANGEROUS_URI_SCHEMES, 'blocked:');
|
||
}
|
||
|
||
/**
|
||
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
|
||
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
|
||
* Optional werden alle String-Werte durch stripHtml geschickt.
|
||
*/
|
||
function pick<T extends object>(obj: T, allowed: readonly string[], options: { stripHtmlFromStrings?: boolean } = {}): Partial<T> {
|
||
const result: Partial<T> = {};
|
||
for (const key of allowed) {
|
||
if (key in obj) {
|
||
let v = (obj as any)[key];
|
||
if (options.stripHtmlFromStrings && typeof v === 'string') {
|
||
v = stripHtml(v);
|
||
}
|
||
(result as any)[key] = v;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
|
||
}
|
||
|
||
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
||
}
|
||
|
||
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, USER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
|
||
}
|
||
|
||
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
||
}
|