Files
opencrm/backend/src/services/contractHistory.service.ts
T
duffyduck 83f1984f12 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>
2026-05-30 19:58:20 +02:00

181 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
export interface CreateHistoryEntryData {
title: string;
description?: string;
isAutomatic?: boolean;
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
*/
export async function getHistoryEntries(contractId: number) {
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) {
const entry = await prisma.contractHistoryEntry.findFirst({
where: { id: entryId, contractId },
});
return entry ? sanitizeEntry(entry) : null;
}
/**
* Neuen Historie-Eintrag erstellen
*/
export async function createHistoryEntry(contractId: number, data: CreateHistoryEntryData) {
// Prüfen ob Vertrag existiert
const contract = await prisma.contract.findUnique({
where: { id: contractId },
});
if (!contract) {
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: stripHtml(data.title) as string,
description: data.description != null ? (stripHtml(data.description) as string) : data.description,
isAutomatic: data.isAutomatic ?? false,
createdBy: data.createdBy,
},
});
}
/**
* Historie-Eintrag aktualisieren (nur manuelle Einträge)
*/
export async function updateHistoryEntry(
contractId: number,
entryId: number,
data: { title?: string; description?: string }
) {
const entry = await prisma.contractHistoryEntry.findFirst({
where: { id: entryId, contractId },
});
if (!entry) {
throw new Error('Historie-Eintrag nicht gefunden');
}
if (entry.isAutomatic) {
throw new Error('Automatische Einträge können nicht bearbeitet werden');
}
return prisma.contractHistoryEntry.update({
where: { id: entryId },
data: {
title: data.title != null ? (stripHtml(data.title) as string) : undefined,
description: data.description != null ? (stripHtml(data.description) as string) : undefined,
},
});
}
/**
* Historie-Eintrag löschen (nur manuelle Einträge)
*/
export async function deleteHistoryEntry(contractId: number, entryId: number) {
const entry = await prisma.contractHistoryEntry.findFirst({
where: { id: entryId, contractId },
});
if (!entry) {
throw new Error('Historie-Eintrag nicht gefunden');
}
if (entry.isAutomatic) {
throw new Error('Automatische Einträge können nicht gelöscht werden');
}
return prisma.contractHistoryEntry.delete({ where: { id: entryId } });
}
/**
* Automatischen Historie-Eintrag für Folgevertrag erstellen (im Vorgängervertrag)
*/
export async function createFollowUpHistoryEntry(
previousContractId: number,
newContractNumber: string,
createdBy: string
) {
return createHistoryEntry(previousContractId, {
title: `Folgevertrag erstellt: ${newContractNumber}`,
description: `Ein neuer Folgevertrag (${newContractNumber}) wurde aus diesem Vertrag erstellt.`,
isAutomatic: true,
createdBy,
});
}
/**
* Automatischen Historie-Eintrag für neuen Folgevertrag erstellen (im neuen Vertrag selbst)
*/
export async function createNewContractFromPredecessorEntry(
newContractId: number,
previousContractNumber: string,
createdBy: string
) {
return createHistoryEntry(newContractId, {
title: `Folgevertrag zu ${previousContractNumber}`,
description: `Dieser Vertrag wurde als Folgevertrag zu ${previousContractNumber} erstellt.`,
isAutomatic: true,
createdBy,
});
}
/**
* Automatischen Historie-Eintrag für VVL (Vertragsverlängerung) im Vorgängervertrag.
*/
export async function createRenewalHistoryEntry(
previousContractId: number,
newContractNumber: string,
createdBy: string
) {
return createHistoryEntry(previousContractId, {
title: `Vertragsverlängerung erstellt: ${newContractNumber}`,
description: `Eine Vertragsverlängerung (VVL) als ${newContractNumber} wurde aus diesem Vertrag erstellt alle Daten wurden 1:1 übernommen, das Auftragsdokument muss neu hochgeladen werden.`,
isAutomatic: true,
createdBy,
});
}
/**
* Automatischen Historie-Eintrag im neuen VVL-Vertrag.
*/
export async function createNewRenewalFromPredecessorEntry(
newContractId: number,
previousContractNumber: string,
createdBy: string
) {
return createHistoryEntry(newContractId, {
title: `VVL zu ${previousContractNumber}`,
description: `Dieser Vertrag wurde als Vertragsverlängerung (VVL) zu ${previousContractNumber} erstellt.`,
isAutomatic: true,
createdBy,
});
}