4ab0340473
R95.1 MEDIUM: foo\r\nBcc:evil@x.de → Header-Injection-Vektor R95.3 LOW: <script>...</script>@x.de → silent stripHtml-Mutation R95.4 LOW: >190 Zeichen → VARCHAR-Overflow → 500 statt 400 Fix: validatePortalUsername() in sanitize.ts mit Whitelist ^[A-Za-z0-9_\-/.@+ ]{0,100}$. Strukturell sind CRLF, Tab, alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra Check. Max 100 → ApiError(400) → R95.4. Raw-Input vor stripHtml geprüft (R87-Pattern). Eingehängt in sanitizeContractBody. R95.2 (Email-Format-Pflicht) bewusst NICHT übernommen: portalUsername ist im Manual-Modus nicht zwingend eine Email (Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als Portal-Login). Doku in SECURITY-HARDENING.md § Runde 95. Frontend: maxLength={100} am Input als UX-Schicht. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
830 lines
34 KiB
TypeScript
830 lines
34 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.
|
||
*/
|
||
|
||
import { ApiError } from './apiError.js';
|
||
|
||
// 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',
|
||
// Pentest 57.7 (2026-06-01): TTL des Public-Consent-Links – kein Leak
|
||
// an die Standard-Customer-Response, da Existenz/Ablaufzeit Info über
|
||
// den Workflow gibt.
|
||
'consentHashExpiresAt',
|
||
// 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;
|
||
|
||
// User-eingabe String-Felder am Contract, die in der UI dargestellt werden.
|
||
// Werden beim Read über stripHtml geschickt, damit Alt-Daten mit rohen
|
||
// XSS-Payloads (vor Einführung von sanitizeContractBody) nicht mehr als
|
||
// `<script>alert(...)</script>` in der Liste auftauchen. Neue Daten sind
|
||
// schon beim Write gestrippt, aber doppelt hält besser.
|
||
const CONTRACT_DISPLAY_STRING_FIELDS = [
|
||
'providerName',
|
||
'tariffName',
|
||
'customerNumberAtProvider',
|
||
'contractNumberAtProvider',
|
||
'orderNumberAtSalesPlatform',
|
||
'customerNumberAtSalesPlatform',
|
||
'contractNumberAtSalesPlatform',
|
||
'portalUsername',
|
||
'previousProviderName',
|
||
'previousCustomerNumber',
|
||
'previousContractNumber',
|
||
'notes',
|
||
// Preisfelder sind im Schema `String?` (freitextlich, nicht numerisch),
|
||
// damit Tarifangaben wie "0,28 €/kWh" oder "27,90 € + 10 € Bonus"
|
||
// möglich sind. Pentest 2026-05-30 (MEDIUM, 42.5): rohe HTML-Payloads
|
||
// in den drei Feldern überlebten den Write-Strip nicht und kommen
|
||
// beim Read 1:1 wieder raus.
|
||
'priceFirst12Months',
|
||
'priceFrom13Months',
|
||
'priceAfter24Months',
|
||
] as const;
|
||
|
||
// User-eingabe String-Felder am Customer für dieselbe Read-Time-Defensive.
|
||
const CUSTOMER_DISPLAY_STRING_FIELDS = [
|
||
'firstName',
|
||
'lastName',
|
||
'companyName',
|
||
'salutation',
|
||
'email',
|
||
'phone',
|
||
'mobile',
|
||
'portalEmail',
|
||
'portalUsername',
|
||
'taxNumber',
|
||
'commercialRegisterNumber',
|
||
'notes',
|
||
] 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];
|
||
}
|
||
for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) {
|
||
if (typeof copy[field] === 'string') {
|
||
copy[field] = stripForDisplay(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.
|
||
*/
|
||
// Sanitisierung für freitextliche User-Notizen (ContractDocument.notes,
|
||
// Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM,
|
||
// 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen
|
||
// roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection-
|
||
// und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt.
|
||
// - Tags + gefährliche Schemata via stripHtml
|
||
// - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren
|
||
// - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen)
|
||
// Pentest 58.1 (MEDIUM, 2026-06-01): documentType wurde nur durch
|
||
// stripHtml geschickt, aber NICHT gegen eine Whitelist geprüft. Damit
|
||
// landeten beliebige Strings (`NICHT_ERLAUBT`, `DROP TABLE …`,
|
||
// Tippfehler-Werte aus alten UI-Versionen) als documentType in der
|
||
// ContractDocument-Tabelle und brachen Frontend-Filter, Auto-Activation
|
||
// (Lieferbestätigung-Trigger) und Reports.
|
||
//
|
||
// Whitelist spiegelt die Konstante CONTRACT_DOCUMENT_TYPES aus
|
||
// SaveAttachmentModal / SaveEmailAsPdfModal im Frontend. Beide
|
||
// Listen MÜSSEN synchron gehalten werden – idealerweise später
|
||
// in eine geteilte Konfiguration gehoben.
|
||
export const ALLOWED_CONTRACT_DOCUMENT_TYPES = [
|
||
'Auftragsformular',
|
||
'Auftragsbestätigung',
|
||
'Lieferbestätigung',
|
||
'Vertragsunterlagen',
|
||
'Vollmacht',
|
||
'Widerrufsbelehrung',
|
||
'Preisblatt',
|
||
'Sonstiges',
|
||
] as const;
|
||
|
||
const CONTRACT_DOCUMENT_TYPE_SET: Set<string> = new Set(ALLOWED_CONTRACT_DOCUMENT_TYPES);
|
||
|
||
/**
|
||
* Validiert + normalisiert einen documentType-Wert. Wirft einen Fehler
|
||
* mit klarer Liste, wenn der Wert nicht in der Whitelist steht (der
|
||
* aufrufende Controller mappt das auf 400). Trimmt Whitespace und macht
|
||
* den Vergleich case-insensitive – damit `"lieferbestätigung"` aus
|
||
* Drittsystemen sauber matched, aber `"Lieferbestätigung_DROP"` rausfliegt.
|
||
*/
|
||
export function validateContractDocumentType(raw: unknown): string {
|
||
if (typeof raw !== 'string') {
|
||
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||
}
|
||
const cleaned = stripHtml(raw) as string;
|
||
const trimmed = cleaned.trim();
|
||
if (trimmed === '') {
|
||
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||
}
|
||
const canonical = ALLOWED_CONTRACT_DOCUMENT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase());
|
||
if (!canonical) {
|
||
throw new Error(`Ungültiger documentType '${trimmed}'. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||
}
|
||
return canonical;
|
||
}
|
||
|
||
// Pentest 26.7 LOW (defense-in-depth, 2026-06-02): documentPath wird
|
||
// (außer beim Upload-Endpoint) NIE direkt aus User-Input übernommen.
|
||
// Falls doch jemand auf die Idee kommt, das Feld irgendwo zu mappen,
|
||
// fangen wir hier Path-Traversal / Javascript-URIs / HTML ab.
|
||
// Spiegelt isValidDocumentPath aus prisma/cleanup-xss-and-mass-assignment.ts
|
||
// 1:1 – Single Source of Truth für Lese- UND Schreibpfad.
|
||
export function isValidDocumentPath(v: string | null | undefined): boolean {
|
||
if (!v) return true; // null/leer ist OK – Feld bleibt einfach unbesetzt
|
||
if (typeof v !== 'string') return false;
|
||
if (v.includes('..')) return false;
|
||
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
||
if (/<[a-z!/]/i.test(v)) return false;
|
||
return /^\/?uploads\/[A-Za-z0-9._\-/]+$/.test(v);
|
||
}
|
||
|
||
export function assertValidDocumentPath(v: string | null | undefined, fieldLabel = 'documentPath'): void {
|
||
if (!isValidDocumentPath(v)) {
|
||
throw new Error(`${fieldLabel} ist kein gültiger Upload-Pfad (erlaubt: /uploads/<safe>).`);
|
||
}
|
||
}
|
||
|
||
// Pentest 68.1 (LOW, 2026-06-03): PDFs mit JavaScript, /Launch (externes
|
||
// Programm), /EmbeddedFile (eingebettete Executables) oder /RichMedia
|
||
// (Flash) kamen durch den reinen Magic-Byte-Check (%PDF-) und wurden
|
||
// inline ausgeliefert. Browser-PDF-Viewer (PDFium/PDF.js) führen kein JS
|
||
// aus, aber sobald jemand die PDF in Adobe Acrobat öffnet, läuft sie.
|
||
// → Wir blocken das schon beim Upload.
|
||
//
|
||
// PDF-Name-Objekte sind laut PDF 32000-1:2008 §7.3.5 case-sensitive, also
|
||
// kein /i auf den Patterns. Whitespace nach `/` ist im Standard zwar
|
||
// erlaubt, in real-world Exploits aber praktisch nie zu sehen – wir
|
||
// bleiben hier pragmatisch.
|
||
//
|
||
// Hinweis: erkannt wird nur, was im Klartext im PDF-Body steht.
|
||
// Komprimierte oder verschlüsselte Streams entgehen dem String-Scan.
|
||
// Für unser Bedrohungsmodell (kompromittierter Staff-Account, LOW) reicht
|
||
// das – ein vollständiger PDF-Parser wäre Overkill.
|
||
const PDF_DANGER_PATTERNS: { pattern: RegExp; label: string }[] = [
|
||
{ pattern: /\/JavaScript\b/, label: 'JavaScript-Action' },
|
||
{ pattern: /\/JS\b/, label: 'JavaScript-Action' },
|
||
{ pattern: /\/Launch\b/, label: 'Launch-Action (externes Programm)' },
|
||
{ pattern: /\/EmbeddedFile\b/, label: 'eingebettete Datei' },
|
||
{ pattern: /\/RichMedia\b/, label: 'RichMedia-Inhalt (Flash)' },
|
||
];
|
||
|
||
export function assertSafePdf(buf: Buffer): void {
|
||
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||
return; // keine PDF → andere Validatoren zuständig
|
||
}
|
||
// Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen.
|
||
// Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive
|
||
// bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug
|
||
// 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF-
|
||
// Object-Stream (außerhalb von `stream..endstream`-Blöcken).
|
||
const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, '');
|
||
for (const { pattern, label } of PDF_DANGER_PATTERNS) {
|
||
if (pattern.test(scanTarget)) {
|
||
throw new ApiError(
|
||
415,
|
||
`PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder
|
||
// dürfen NIE CRLF oder andere Control-Chars enthalten – sonst sind sie
|
||
// ein Header-Injection-Vektor (Mail, HTTP), wenn der Wert mal in einen
|
||
// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf
|
||
// literales Space beschränkt, NICHT `\s` – das matched sonst `\r\n\t`.
|
||
// Allowed: Ziffern, Plus, Minus, Slash, Klammern, Punkt, Space. Bis 40 Zeichen.
|
||
//
|
||
// 51.3 deckte nur Contract-Phone-Felder ab; 60.3: `Customer.phone` /
|
||
// `Customer.mobile` waren immer noch offen, weil pickCustomerUpdate nur
|
||
// stripHtml laufen ließ – das filtert keine Control-Chars.
|
||
const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/;
|
||
export function sanitizePhoneField(raw: unknown, fieldLabel: string): string | undefined {
|
||
if (raw == null) return undefined;
|
||
const trimmed = String(raw).trim();
|
||
if (trimmed === '') return undefined;
|
||
if (!PHONE_FIELD_ALLOWED.test(trimmed)) {
|
||
throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
// Pentest 62.7 (LOW, 2026-06-02): deliveryDate/confirmationDate-Felder
|
||
// liefen ungeprüft in maybeActivateOnDeliveryConfirmation. XSS-Payloads
|
||
// gingen mit 200 durch, weil das ungültige Datum nur silent als null
|
||
// behandelt wurde. Impact gering, aber API-Hygiene: ungültige Eingabe
|
||
// soll 400 zurückgeben, nicht 200.
|
||
//
|
||
// Akzeptiert: ISO-8601-Datum (YYYY-MM-DD) und Datum+Zeit (mit oder ohne
|
||
// Zeitzone). Whitespace wird getrimmt. null / leerer String / undefined
|
||
// sind OK – der Aufrufer behandelt das als "Datum nicht gesetzt".
|
||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
||
export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): string | null {
|
||
if (raw == null) return null;
|
||
if (typeof raw !== 'string') {
|
||
throw new Error(`${fieldLabel} muss ein Datums-String (YYYY-MM-DD) sein.`);
|
||
}
|
||
const trimmed = raw.trim();
|
||
if (trimmed === '') return null;
|
||
if (!ISO_DATE_REGEX.test(trimmed)) {
|
||
throw new Error(`${fieldLabel} muss ISO-8601-Format haben (YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS).`);
|
||
}
|
||
const parsed = new Date(trimmed);
|
||
if (isNaN(parsed.getTime())) {
|
||
throw new Error(`${fieldLabel} ist kein gültiges Datum.`);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
// Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags-
|
||
// Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder
|
||
// Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen
|
||
// 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem
|
||
// überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)`
|
||
// die stripHtml-Defense (kein umschließender Tag → kein Match), die
|
||
// in PDF-/Mail-Export potentiell aktiv werden könnten.
|
||
//
|
||
// Whitelist orientiert sich am Vorschlag des Pentesters
|
||
// `^[\w\-\/\s\.]*$` – Whitespace ist hier bewusst NUR ein literales
|
||
// Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor
|
||
// in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale
|
||
// Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)-
|
||
// Limit der DB-Spalte.
|
||
const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet<string> = new Set([
|
||
'customerNumberAtProvider',
|
||
'contractNumberAtProvider',
|
||
'orderNumberAtSalesPlatform',
|
||
'customerNumberAtSalesPlatform',
|
||
'contractNumberAtSalesPlatform',
|
||
]);
|
||
const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/;
|
||
const CONTRACT_IDENTIFIER_MAX_LEN = 100;
|
||
|
||
export function isContractIdentifierField(key: string): boolean {
|
||
return CONTRACT_IDENTIFIER_FIELDS.has(key);
|
||
}
|
||
|
||
export function validateContractIdentifier(
|
||
raw: unknown,
|
||
fieldLabel: string,
|
||
): string | null {
|
||
if (raw == null) return null;
|
||
if (typeof raw !== 'string') {
|
||
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||
}
|
||
const trimmed = raw.trim();
|
||
if (trimmed === '') return null;
|
||
if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) {
|
||
throw new ApiError(
|
||
400,
|
||
`${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`,
|
||
);
|
||
}
|
||
if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) {
|
||
throw new ApiError(
|
||
400,
|
||
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`,
|
||
);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
// Pentest 95.1/95.3/95.4 (MEDIUM/LOW, 2026-06-21): Manuelles
|
||
// `portalUsername` am Vertrag hatte gar keine Validierung. Drei
|
||
// nachweisbare Effekte:
|
||
// - `foo\r\nBcc:evil@x.de` (CRLF) verbatim gespeichert →
|
||
// Header-Injection-Vektor sobald der Wert in Mail-Templates
|
||
// oder PDF-Footers landet.
|
||
// - `<script>alert(1)</script>@x.de` lief durch stripHtml →
|
||
// stille Mutation (R87.1/R89.2-Pattern auf neuem Feld).
|
||
// - >190 Zeichen → VARCHAR-Overflow → generischer 500 statt 400.
|
||
//
|
||
// Bewusst NICHT übernommen wurde R95.2 (Email-Format-Pflicht):
|
||
// `portalUsername` ist im Manual-Modus nicht zwingend eine
|
||
// E-Mail. Vodafone, 1&1, EWE und etliche Stadtwerke nutzen
|
||
// Kundennummern, Pseudonyme oder Customer-IDs als Portal-Login.
|
||
// Eine `email().regex()`-Pflicht würde legitime Logins ablehnen.
|
||
// Der Stressfrei-Modus hängt eh an einer schon validierten
|
||
// Email-Stammdate (assertValidForwardingEmail).
|
||
//
|
||
// Allowed: Alphanumerisch + `_`, `-`, `.`, `/`, `@`, `+`, Space.
|
||
// Damit sind Vodafone-Kunden-IDs (`12345678`), Pseudonyme
|
||
// (`max.mustermann`), Plus-Tag-Emails (`m+tag@example.com`)
|
||
// und gemischte Formen abgedeckt. Strukturell sind CRLF, Tab,
|
||
// alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne
|
||
// extra Check. Max 100 Zeichen << VARCHAR(191) → R95.4.
|
||
const PORTAL_USERNAME_ALLOWED = /^[A-Za-z0-9_\-/.@+ ]{0,100}$/;
|
||
const PORTAL_USERNAME_MAX_LEN = 100;
|
||
|
||
export function validatePortalUsername(
|
||
raw: unknown,
|
||
fieldLabel = 'portalUsername',
|
||
): string | null {
|
||
if (raw == null) return null;
|
||
if (typeof raw !== 'string') {
|
||
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||
}
|
||
const trimmed = raw.trim();
|
||
if (trimmed === '') return null;
|
||
if (trimmed.length > PORTAL_USERNAME_MAX_LEN) {
|
||
throw new ApiError(
|
||
400,
|
||
`${fieldLabel} darf maximal ${PORTAL_USERNAME_MAX_LEN} Zeichen lang sein.`,
|
||
);
|
||
}
|
||
if (!PORTAL_USERNAME_ALLOWED.test(trimmed)) {
|
||
throw new ApiError(
|
||
400,
|
||
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, @, +, Leerzeichen).`,
|
||
);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
// Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am
|
||
// Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes
|
||
// hat das Length-Cap silent durchgeschoben (slice statt Error) und
|
||
// stripHtml lief vor dem Length-Check – derselbe Fehler wie R87:
|
||
// `<script>…</script>` reduziert auf leeren String → null in der
|
||
// DB → vorheriger Wert ohne Fehlermeldung überschrieben.
|
||
//
|
||
// Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation.
|
||
// - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80)
|
||
// - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML)
|
||
// - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc)
|
||
// - leerer/getrimmt-leerer Input → null (Feld zurücksetzen)
|
||
// - CRLF → LF normalisieren
|
||
const PROVIDER_ADDRESS_MAX_LEN = 500;
|
||
// Erlaubt: nur LF (`\x0A`) als Newline. Alles andere – inkl. Tab (`\x09`) –
|
||
// fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail
|
||
// und nichts, was ein Mensch je tippt.
|
||
const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/;
|
||
|
||
export function validateProviderAddress(
|
||
raw: unknown,
|
||
fieldLabel: string,
|
||
): string | null {
|
||
if (raw == null) return null;
|
||
if (typeof raw !== 'string') {
|
||
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||
}
|
||
// CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen – ein Editor der
|
||
// immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei
|
||
// Zeichen gegen das 500er-Cap zählen.
|
||
const normalized = raw.replace(/\r\n?/g, '\n');
|
||
if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) {
|
||
throw new ApiError(
|
||
400,
|
||
`${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`,
|
||
);
|
||
}
|
||
if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) {
|
||
throw new ApiError(
|
||
400,
|
||
`${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`,
|
||
);
|
||
}
|
||
const trimmed = normalized.trim();
|
||
return trimmed === '' ? null : trimmed;
|
||
}
|
||
|
||
const NOTES_DEFAULT_MAX = 2000;
|
||
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
|
||
if (raw == null) return null;
|
||
if (typeof raw !== 'string') return null;
|
||
const stripped = stripHtml(raw) as string;
|
||
// CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n
|
||
// herausfiltern. Null/Form-Feed/Tabs raus.
|
||
const normalized = stripped
|
||
.replace(/\r\n?/g, '\n')
|
||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||
const trimmed = normalized.trim();
|
||
if (trimmed === '') return null;
|
||
return trimmed.slice(0, maxLength);
|
||
}
|
||
|
||
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
|
||
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
|
||
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
|
||
// oder Preisfeld ist er nur kosmetischer Müll.
|
||
// Pentest 2026-05-30 (INFO, 43.5): `javascript:alert(1)` in
|
||
// priceFirst12Months wurde als "blocked:alert(1)" angezeigt.
|
||
function stripForDisplay(value: unknown): unknown {
|
||
const stripped = stripHtml(value);
|
||
if (typeof stripped === 'string' && stripped.includes('blocked:')) {
|
||
return stripped.replace(/blocked:/g, '').trim();
|
||
}
|
||
return stripped;
|
||
}
|
||
|
||
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];
|
||
}
|
||
for (const field of CONTRACT_DISPLAY_STRING_FIELDS) {
|
||
if (typeof copy[field] === 'string') {
|
||
copy[field] = stripForDisplay(copy[field]);
|
||
}
|
||
}
|
||
// Nested: previousProviderName liegt im energyDetails-Sub-Objekt
|
||
if (copy.energyDetails && typeof copy.energyDetails === 'object') {
|
||
const ed = copy.energyDetails as Record<string, unknown>;
|
||
if (typeof ed.previousProviderName === 'string') {
|
||
ed.previousProviderName = stripHtml(ed.previousProviderName);
|
||
}
|
||
if (typeof ed.previousCustomerNumber === 'string') {
|
||
ed.previousCustomerNumber = stripHtml(ed.previousCustomerNumber);
|
||
}
|
||
}
|
||
// Nested: previousContract wird rekursiv auch sanitisiert
|
||
if (copy.previousContract && typeof copy.previousContract === 'object') {
|
||
copy.previousContract = sanitizeContract(copy.previousContract as Record<string, unknown>);
|
||
}
|
||
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 });
|
||
}
|