Files
opencrm/backend/src/utils/sanitize.ts
T
duffyduck adc3b70492 Pentest 2026-05-20 MEDIUM+LOW Follow-ups
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>
2026-05-20 01:13:19 +02:00

297 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });
}