From 83f1984f1265014f93f13a5b4c4973e58cc45838 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 30 May 2026 19:58:20 +0200 Subject: [PATCH] Pentest 43.6 MEDIUM + 43.5 INFO: History-XSS + blocked:-Marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 43.6 MEDIUM: ContractHistoryEntry.title + .description waren auf beiden Pfaden ungestrippt – Admin konnte HTML/Script-Tags einschreiben, Portal-User las sie roh zurück. Fix: stripHtml() auf Create + Update (Write-Pfad) und sanitizeEntry() im List + Get (Read-Pfad), damit Alt-Daten ebenfalls clean rausgehen. 43.5 INFO: stripHtml ersetzt javascript: -> blocked: – sinnvoll bei URL-Feldern, hässlich in Tarif-/Preis-Namen ("blocked:alert(1)" als Preis). Neuer stripForDisplay-Wrapper entfernt den Marker zusätzlich in CONTRACT_DISPLAY_STRING_FIELDS + CUSTOMER_DISPLAY_ STRING_FIELDS. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/contractHistory.service.ts | 29 +++++++++++++++---- backend/src/utils/sanitize.ts | 18 ++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/backend/src/services/contractHistory.service.ts b/backend/src/services/contractHistory.service.ts index a4fe794f..39d31642 100644 --- a/backend/src/services/contractHistory.service.ts +++ b/backend/src/services/contractHistory.service.ts @@ -1,4 +1,5 @@ import prisma from '../lib/prisma.js'; +import { stripHtml } from '../utils/sanitize.js'; export interface CreateHistoryEntryData { title: string; @@ -7,23 +8,36 @@ export interface CreateHistoryEntryData { createdBy: string; } +// Read-Time-Defensive: title + description durch stripHtml schicken, damit +// Alt-Einträge (vor Pentest 43.6) mit rohen HTML-Payloads nicht roh +// rausgehen. Schützt zusätzlich gegen einen umgangenen Write-Filter. +function sanitizeEntry(entry: T): T { + return { + ...entry, + title: stripHtml(entry.title) as string, + description: entry.description != null ? (stripHtml(entry.description) as string) : entry.description, + }; +} + /** * Alle Historie-Einträge für einen Vertrag abrufen */ export async function getHistoryEntries(contractId: number) { - return prisma.contractHistoryEntry.findMany({ + const entries = await prisma.contractHistoryEntry.findMany({ where: { contractId }, orderBy: { createdAt: 'desc' }, }); + return entries.map(sanitizeEntry); } /** * Einzelnen Historie-Eintrag abrufen */ export async function getHistoryEntry(contractId: number, entryId: number) { - return prisma.contractHistoryEntry.findFirst({ + const entry = await prisma.contractHistoryEntry.findFirst({ where: { id: entryId, contractId }, }); + return entry ? sanitizeEntry(entry) : null; } /** @@ -39,11 +53,14 @@ export async function createHistoryEntry(contractId: number, data: CreateHistory throw new Error('Vertrag nicht gefunden'); } + // Pentest 2026-05-30 (MEDIUM, 43.6): Admin konnte HTML-Tags in title + + // description schreiben, Portal-User las sie roh zurück. stripHtml räumt + // Tags + gefährliche URI-Schemata vor dem Persistieren weg. return prisma.contractHistoryEntry.create({ data: { contractId, - title: data.title, - description: data.description, + title: stripHtml(data.title) as string, + description: data.description != null ? (stripHtml(data.description) as string) : data.description, isAutomatic: data.isAutomatic ?? false, createdBy: data.createdBy, }, @@ -73,8 +90,8 @@ export async function updateHistoryEntry( return prisma.contractHistoryEntry.update({ where: { id: entryId }, data: { - title: data.title, - description: data.description, + title: data.title != null ? (stripHtml(data.title) as string) : undefined, + description: data.description != null ? (stripHtml(data.description) as string) : undefined, }, }); } diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 7fc57bfe..b02dbabf 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -122,7 +122,7 @@ export function sanitizeCustomer>(customer: T } for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) { if (typeof copy[field] === 'string') { - copy[field] = stripHtml(copy[field]); + copy[field] = stripForDisplay(copy[field]); } } if (Array.isArray(copy.contracts)) { @@ -166,6 +166,20 @@ export function sanitizeCustomers>(customers: * Provider-Passwort (nur über den dedizierten /password-Endpoint mit * Audit-Log abrufbar) und sanitisiert das embedded customer. */ +// 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>(contract: T | null): T | null { if (!contract) return contract; const copy: Record = { ...contract }; @@ -174,7 +188,7 @@ export function sanitizeContract>(contract: T } for (const field of CONTRACT_DISPLAY_STRING_FIELDS) { if (typeof copy[field] === 'string') { - copy[field] = stripHtml(copy[field]); + copy[field] = stripForDisplay(copy[field]); } } // Nested: previousProviderName liegt im energyDetails-Sub-Objekt