Anzeige-Fix: HTML in providerName/tariffName etc. beim Read strippen

In der Vertragsübersicht tauchen rohe <script>/<img>-Payloads als
Plaintext auf – React escaped sie zwar (kein XSS), sie sehen aber
hässlich aus. Ursprung: Daten aus pre-Pentest-Zeit, bevor
sanitizeContractBody beim Write existierte.

Fix: sanitizeContract und sanitizeCustomer strippen jetzt zusätzlich
HTML in den definierten Display-Feldern (providerName, tariffName,
customerNumberAtProvider, firstName, lastName, companyName, etc.).
Wirkt auch auf nested previousContract + energyDetails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:55:59 +02:00
parent 0d024b94c2
commit 95b7261227
+57
View File
@@ -59,6 +59,39 @@ const PORTAL_HIDDEN_CONTRACT_FIELDS = [
'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',
'portalUsername',
'previousProviderName',
'previousCustomerNumber',
'previousContractNumber',
'notes',
] 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',
@@ -79,6 +112,11 @@ export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T
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] = stripHtml(copy[field]);
}
}
if (Array.isArray(copy.contracts)) {
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
}
@@ -126,6 +164,25 @@ export function sanitizeContract<T extends Record<string, unknown>>(contract: T
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] = stripHtml(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>);
}