Pentest 43.6 MEDIUM + 43.5 INFO: History-XSS + blocked:-Marker
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { stripHtml } from '../utils/sanitize.js';
|
||||||
|
|
||||||
export interface CreateHistoryEntryData {
|
export interface CreateHistoryEntryData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,23 +8,36 @@ export interface CreateHistoryEntryData {
|
|||||||
createdBy: string;
|
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<T extends { title: string; description: string | null }>(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
|
* Alle Historie-Einträge für einen Vertrag abrufen
|
||||||
*/
|
*/
|
||||||
export async function getHistoryEntries(contractId: number) {
|
export async function getHistoryEntries(contractId: number) {
|
||||||
return prisma.contractHistoryEntry.findMany({
|
const entries = await prisma.contractHistoryEntry.findMany({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
return entries.map(sanitizeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einzelnen Historie-Eintrag abrufen
|
* Einzelnen Historie-Eintrag abrufen
|
||||||
*/
|
*/
|
||||||
export async function getHistoryEntry(contractId: number, entryId: number) {
|
export async function getHistoryEntry(contractId: number, entryId: number) {
|
||||||
return prisma.contractHistoryEntry.findFirst({
|
const entry = await prisma.contractHistoryEntry.findFirst({
|
||||||
where: { id: entryId, contractId },
|
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');
|
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({
|
return prisma.contractHistoryEntry.create({
|
||||||
data: {
|
data: {
|
||||||
contractId,
|
contractId,
|
||||||
title: data.title,
|
title: stripHtml(data.title) as string,
|
||||||
description: data.description,
|
description: data.description != null ? (stripHtml(data.description) as string) : data.description,
|
||||||
isAutomatic: data.isAutomatic ?? false,
|
isAutomatic: data.isAutomatic ?? false,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
},
|
},
|
||||||
@@ -73,8 +90,8 @@ export async function updateHistoryEntry(
|
|||||||
return prisma.contractHistoryEntry.update({
|
return prisma.contractHistoryEntry.update({
|
||||||
where: { id: entryId },
|
where: { id: entryId },
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title != null ? (stripHtml(data.title) as string) : undefined,
|
||||||
description: data.description,
|
description: data.description != null ? (stripHtml(data.description) as string) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T
|
|||||||
}
|
}
|
||||||
for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) {
|
for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) {
|
||||||
if (typeof copy[field] === 'string') {
|
if (typeof copy[field] === 'string') {
|
||||||
copy[field] = stripHtml(copy[field]);
|
copy[field] = stripForDisplay(copy[field]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(copy.contracts)) {
|
if (Array.isArray(copy.contracts)) {
|
||||||
@@ -166,6 +166,20 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
|
|||||||
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
||||||
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
* 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<T extends Record<string, unknown>>(contract: T | null): T | null {
|
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
|
||||||
if (!contract) return contract;
|
if (!contract) return contract;
|
||||||
const copy: Record<string, unknown> = { ...contract };
|
const copy: Record<string, unknown> = { ...contract };
|
||||||
@@ -174,7 +188,7 @@ export function sanitizeContract<T extends Record<string, unknown>>(contract: T
|
|||||||
}
|
}
|
||||||
for (const field of CONTRACT_DISPLAY_STRING_FIELDS) {
|
for (const field of CONTRACT_DISPLAY_STRING_FIELDS) {
|
||||||
if (typeof copy[field] === 'string') {
|
if (typeof copy[field] === 'string') {
|
||||||
copy[field] = stripHtml(copy[field]);
|
copy[field] = stripForDisplay(copy[field]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Nested: previousProviderName liegt im energyDetails-Sub-Objekt
|
// Nested: previousProviderName liegt im energyDetails-Sub-Objekt
|
||||||
|
|||||||
Reference in New Issue
Block a user