9cf8c505af
28.1 Restarbeit (URI-Schemata):
DANGEROUS_URI_SCHEMES jetzt vollstaendig – blob:, about:, ws:, wss:,
ldap:, dict: ergaenzt. http(s):, mailto:, tel: bewusst nicht
geblockt (legitime URLs in Notizfeldern).
29.1 Cyrillic-Homoglyph:
"jаvascript:" mit U+0430 lief durch die Regex. HOMOGLYPH_TO_ASCII-
Map (а→a, е→e, о→o, …, 13 Eintraege) wird VOR dem Scheme-Strip
angewendet.
29.2 Percent-Encoding:
"java%73cript:" und "java%2573cript:" umgingen den Filter.
percentDecode() laeuft jetzt iterativ bis zu 5 Runden.
29.3 Zero-Width-Joiner:
"javascript:" mit U+200B/200C/200D etc. zerteilte die Regex-
Matches. ZERO_WIDTH_CHARS-Regex strippt alle unsichtbaren Unicode-
Steuerzeichen, bevor irgendwas anderes laeuft.
28.3 Partial (PDF-Validierung tiefer):
Magic-Bytes allein reichten nicht – "%PDF-1.4\n#!/bin/bash" kam
durch. Jetzt zusaetzlich %%EOF-Marker in den letzten 1 KB +
Pattern-Scan der ersten 4 KB auf #!/, <script, <?php, <%, "MZ "
(PE-Header).
29.4 Email-Format-Validator:
neuer isValidEmail() lehnt Whitespace/Newlines (SMTP-Header-
Injection-Vektor) und Format-Muell ab. Verdrahtet in
create/update Customer + User + updatePortalSettings.
29.5 GET /api/providers/email 500 -> 404:
parseInt("email") = NaN, Prisma crashte. Controller validiert jetzt
Number.isFinite(id) und liefert 404.
Live-verifiziert auf dev: 13 Test-Cases (alle Schema-Varianten,
Homoglyphe, Percent, ZWJ, PDF-Validierung, Email-Format,
/providers/email) – alle erwarteten Antworten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
407 lines
16 KiB
TypeScript
407 lines
16 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.
|
||
*
|
||
* Verlauf:
|
||
* - Pentest Runde 11 (2026-05-18): <script>… in companyName.
|
||
* - Pentest 2026-05-20 (28.1/28.2): URI-Schema-Liste + Entity-Decoding.
|
||
* - Pentest 2026-05-20 (29.1/29.2/29.3): Zero-Width-Chars,
|
||
* Percent-Encoding und Cyrillic-Homoglyph-Bypass. Reihenfolge ist
|
||
* wichtig: zuerst Unicode-Müll raus, dann percent + entity dekodieren,
|
||
* dann Homoglyphe normalisieren, ZULETZT Tag-/Scheme-Strip.
|
||
* - Pentest 2026-05-20 (28.1 Restarbeit): blob:/about:/ws:/wss:/
|
||
* ldap:/dict: ergänzt.
|
||
*/
|
||
|
||
// Schemes die wir aktiv blocken – durch "blocked:" ersetzen statt
|
||
// löschen, damit legitimer Text drumherum erhalten bleibt.
|
||
// Bewusst nicht in der Liste: http(s):, mailto:, tel: (legitime URLs in
|
||
// Notizfeldern). Alles andere geht selten in einem Plain-Text-Feld vor
|
||
// und kann im worst case immer noch durch JS interpretiert werden.
|
||
const DANGEROUS_URI_SCHEMES =
|
||
/(?:javascript|data|vbscript|file|ftp|blob|about|ws|wss|ldap|dict)\s*:/gi;
|
||
|
||
// Unsichtbare Unicode-Steuerzeichen, die wie Whitespace fehlen, aber
|
||
// Regex-Matches auf "javascript:" zerteilen können. Plain-Text-Felder
|
||
// enthalten diese nie legitim. Pentest 29.3.
|
||
// U+200B–U+200F (ZWSP, ZWNJ, ZWJ, LRM, RLM), U+202A–U+202E (Embedding/
|
||
// Override), U+2060–U+2064 (Word-Joiner & co.), U+FEFF (BOM).
|
||
const ZERO_WIDTH_CHARS = /[---]/g;
|
||
|
||
// Cyrillic/Greek-Homoglyphe, die in URL-Schemes als Spoofing taugen
|
||
// (Pentest 29.1: "jаvascript:" mit kyrillischem а = U+0430).
|
||
// Bewusst eng gehalten: nur die Buchstaben, die in JS-/HTML-Schlüssel-
|
||
// wörtern vorkommen – wir wollen legitimes Russisch/Griechisch (z.B. in
|
||
// einem Notizfeld) nicht komplett zerlegen. Das Risiko, dass ein
|
||
// einzelnes "а" in einem ru-Wort versehentlich zu "a" wird, ist
|
||
// akzeptabel – die häufiger genutzten cyrillischen Buchstaben (б, в,
|
||
// г, …) sind nicht in der Map.
|
||
const HOMOGLYPH_TO_ASCII: Record<string, string> = {
|
||
'а': 'a', 'А': 'A', // U+0430 / U+0410
|
||
'е': 'e', 'Е': 'E', // U+0435 / U+0415
|
||
'о': 'o', 'О': 'O', // U+043E / U+041E
|
||
'р': 'p', 'Р': 'P', // U+0440 / U+0420
|
||
'с': 'c', 'С': 'C', // U+0441 / U+0421
|
||
'у': 'y', 'У': 'Y', // U+0443 / U+0423
|
||
'х': 'x', 'Х': 'X', // U+0445 / U+0425
|
||
'і': 'i', 'І': 'I', // U+0456 / U+0406 (Ukr.)
|
||
'ј': 'j', 'Ј': 'J', // U+0458 / U+0408 (Mac.)
|
||
'ѕ': 's', 'Ѕ': 'S', // U+0455 / U+0405
|
||
'ο': 'o', 'Ο': 'O', // U+03BF / U+039F (Greek)
|
||
'α': 'a', // U+03B1
|
||
};
|
||
const HOMOGLYPH_RE = new RegExp(Object.keys(HOMOGLYPH_TO_ASCII).join('|'), 'g');
|
||
|
||
function decodeHtmlEntities(s: string): string {
|
||
return s
|
||
.replace(/&#(\d+);?/g, (_m, code) => {
|
||
const n = parseInt(code, 10);
|
||
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
|
||
})
|
||
.replace(/&#x([0-9a-fA-F]+);?/g, (_m, code) => {
|
||
const n = parseInt(code, 16);
|
||
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
|
||
})
|
||
.replace(/</gi, '<')
|
||
.replace(/>/gi, '>')
|
||
.replace(/"/gi, '"')
|
||
.replace(/&#?apos;/gi, "'")
|
||
.replace(/&/gi, '&');
|
||
}
|
||
|
||
// Percent-decoded String. Iterativ bis stabil – ein Angreifer kann
|
||
// "java%2573cript:" schreiben (`%25` ist `%`), das nach einer
|
||
// Iteration zu "java%73cript:" wird, was wiederum zu "javascript:".
|
||
// Max 5 Iterationen reichen für realistische Verschachtelungen.
|
||
// `decodeURIComponent` würde bei ungültigen Sequenzen werfen, deshalb
|
||
// machen wir es per Regex.
|
||
function percentDecode(s: string): string {
|
||
let prev = s;
|
||
for (let i = 0; i < 5; i++) {
|
||
const next = prev.replace(/%([0-9A-Fa-f]{2})/g, (_m, hex) =>
|
||
String.fromCharCode(parseInt(hex, 16)),
|
||
);
|
||
if (next === prev) return next;
|
||
prev = next;
|
||
}
|
||
return prev;
|
||
}
|
||
|
||
/**
|
||
* Strikte Email-Validierung. Wichtig vor allem für **SMTP-Header-
|
||
* Injection**: ein gespeicherter Wert wie `test@x.de\nBcc:attacker@evil.de`
|
||
* würde, wenn er in einen To-Header geschrieben wird, einen zusätzlichen
|
||
* Bcc-Empfänger einschleusen (Pentest 29.4). Wir verbieten daher:
|
||
* - Whitespace, Newlines, Tabs, Steuerzeichen
|
||
* - mehr als ein `@`
|
||
* - Domain ohne Punkt
|
||
* - Länge > 254 (RFC 5321)
|
||
* Format: `local@domain.tld`, local 1–64 ASCII, domain DNS-konform.
|
||
*
|
||
* `null`/leer ist erlaubt (Email ist oft optional). Aufrufer entscheidet,
|
||
* ob `null` ok ist.
|
||
*/
|
||
export function isValidEmail(value: unknown): boolean {
|
||
if (value === null || value === undefined || value === '') return true;
|
||
if (typeof value !== 'string') return false;
|
||
if (value.length > 254) return false;
|
||
// Newline/Tab/Steuerzeichen explizit ablehnen – das ist der
|
||
// Header-Injection-Vektor.
|
||
if (/[\r\n\t\0\v\f]/.test(value)) return false;
|
||
// Basic RFC-5322-ähnlich, ohne quoted-string-Local-Part.
|
||
// Local-Part: 1–64 Zeichen aus [a-z0-9._%+-], muss nicht mit Punkt
|
||
// beginnen/enden. Domain: Labels mit Punkten, TLD mind. 2 Zeichen.
|
||
return /^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(value);
|
||
}
|
||
|
||
export function stripHtml(value: unknown): unknown {
|
||
if (typeof value !== 'string') return value;
|
||
let s = value;
|
||
// 1) Unicode-Steuerzeichen raus, sonst zerteilen sie Regex-Matches.
|
||
s = s.replace(ZERO_WIDTH_CHARS, '');
|
||
// 2) Percent-Encoding auflösen (iterativ bis stabil).
|
||
s = percentDecode(s);
|
||
// 3) Homoglyphe normalisieren, damit "jаvascript:" zu "javascript:" wird.
|
||
s = s.replace(HOMOGLYPH_RE, (m) => HOMOGLYPH_TO_ASCII[m] || m);
|
||
// 4) HTML-Entities dekodieren.
|
||
s = decodeHtmlEntities(s);
|
||
// 5) Tags + dangerous Schemes strippen.
|
||
s = s
|
||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||
.replace(DANGEROUS_URI_SCHEMES, 'blocked:');
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* 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 });
|
||
}
|