2d3ca28691
`pickUserUpdate`-Whitelist enthielt `hasGdprAccess` und `hasDeveloperAccess` nicht – sie wurden vom Mass-Assignment-Schutz aus dem Request entfernt, bevor sie den Service erreichen konnten. Damit lief `setUserGdprAccess` / `setUserDeveloperAccess` nie und die zwei versteckten Rollen blieben unzuweisbar (UI-Checkbox hatte keine Wirkung). Fix: Beide Felder zur Whitelist hinzugefügt – sie sind keine User-Spalten, der Service mappt sie auf die DSGVO-/Developer-Rollen. Bonus: Audit-Log-Diff vergleicht jetzt den Pre-State korrekt (User-Rollen in `before` mitgeladen + Field-Labels), sonst hätte der jetzt durchkommende Flag immer einen False-Positive-Change "- → Ja" produziert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
4.8 KiB
TypeScript
153 lines
4.8 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',
|
||
] 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.
|
||
*/
|
||
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
|
||
if (!customer) return customer;
|
||
const copy = { ...customer };
|
||
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
|
||
delete copy[field];
|
||
}
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
|
||
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
|
||
*/
|
||
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;
|
||
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 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',
|
||
'password', // nur Admin, wird im Service gehashed
|
||
// 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
|
||
] as const;
|
||
|
||
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
|
||
|
||
/**
|
||
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
|
||
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
|
||
*/
|
||
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
|
||
const result: Partial<T> = {};
|
||
for (const key of allowed) {
|
||
if (key in obj) {
|
||
(result as any)[key] = (obj as any)[key];
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
|
||
}
|
||
|
||
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
|
||
}
|
||
|
||
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
|
||
}
|
||
|
||
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
|
||
return pick((body as object) || {}, USER_CREATE_FIELDS);
|
||
}
|