Files
opencrm/backend/src/utils/sanitize.ts
T
duffyduck 4ab0340473 Pentest R95: portalUsername (Manual-Modus) härten
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>
2026-06-21 15:24:57 +02:00

830 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
*/
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+200BU+200F (ZWSP, ZWNJ, ZWJ, LRM, RLM), U+202AU+202E (Embedding/
// Override), U+2060U+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(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#?apos;/gi, "'")
.replace(/&amp;/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 164 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: 164 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 });
}