adc3b70492
MEDIUM – Consent-Mass-Assignment:
PUT /api/gdpr/customer/:id/consents/:type nahm source/documentPath/
version ungefiltert aus dem Body. Portal-User konnte
source="ADMIN_OVERRIDE", version="<script>" oder
documentPath="../../etc/passwd" durchschmuggeln.
Fix: nur status aus Body, source server-seitig auf "portal"
hardcoded, documentPath/version bleiben NULL (werden dediziert
vom Authorization-Upload server-seitig gesetzt). Whitelist
ALLOWED_CONSENT_SOURCES für source-Werte. grantAuthorization
(Admin) erzwingt die Whitelist ebenfalls; notes läuft jetzt
durch stripHtml.
LOW – javascript:-URI in companyName:
stripHtml() entfernte HTML-Tags, ließ aber javascript:/data:/
vbscript:-Schemata stehen. companyName="javascript:alert(1)"
hätte in <a href={companyName}> aktiv werden können.
Fix: stripHtml ersetzt jene Schemata mit "blocked:" – legitimer
Text bleibt unangetastet, das Schema wird unschädlich.
LOW – documentPath ohne Validierung:
Bereits durch obigen Consent-Fix erledigt; Cleanup-Pass strippt
zusätzlich vorhandene dreckige Pfade.
cleanup-xss-and-mass-assignment.ts: neue cleanupConsents() läuft
beim Container-Start, normalisiert source per Whitelist auf
"unknown" + stripHtml über version/documentPath.
Live-verifiziert auf dev (alle drei Payloads geblockt + Cleanup
auf dirty DB greift).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
7.6 KiB
TypeScript
286 lines
7.6 KiB
TypeScript
import { ConsentType, ConsentStatus } from '@prisma/client';
|
||
import prisma from '../lib/prisma.js';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
|
||
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
||
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
||
// 'public-link', CRM-Backend-Override 'crm-backend'. Alles andere
|
||
// (z.B. "ADMIN_OVERRIDE", "<script>") wird abgelehnt – Pentest 2026-05-20.
|
||
export const ALLOWED_CONSENT_SOURCES: ReadonlySet<string> = new Set([
|
||
'portal',
|
||
'public-link',
|
||
'telefon',
|
||
'papier',
|
||
'email',
|
||
'crm-backend',
|
||
]);
|
||
|
||
export function sanitizeConsentSource(value: unknown, fallback: string): string {
|
||
const v = typeof value === 'string' ? value : '';
|
||
return ALLOWED_CONSENT_SOURCES.has(v) ? v : fallback;
|
||
}
|
||
|
||
export interface UpdateConsentData {
|
||
status: ConsentStatus;
|
||
source?: string;
|
||
documentPath?: string;
|
||
version?: string;
|
||
ipAddress?: string;
|
||
createdBy: string;
|
||
}
|
||
|
||
/**
|
||
* Holt alle Einwilligungen eines Kunden
|
||
*/
|
||
export async function getCustomerConsents(customerId: number) {
|
||
const consents = await prisma.customerConsent.findMany({
|
||
where: { customerId },
|
||
orderBy: { consentType: 'asc' },
|
||
});
|
||
|
||
// Alle verfügbaren Consent-Typen mit Status
|
||
const allTypes = Object.values(ConsentType);
|
||
const consentMap = new Map(consents.map((c) => [c.consentType, c]));
|
||
|
||
return allTypes.map((type) => {
|
||
const existing = consentMap.get(type);
|
||
return existing || {
|
||
id: null,
|
||
customerId,
|
||
consentType: type,
|
||
status: 'PENDING' as ConsentStatus,
|
||
grantedAt: null,
|
||
withdrawnAt: null,
|
||
source: null,
|
||
documentPath: null,
|
||
version: null,
|
||
ipAddress: null,
|
||
createdBy: null,
|
||
createdAt: null,
|
||
updatedAt: null,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Aktualisiert oder erstellt eine Einwilligung
|
||
*/
|
||
export async function updateConsent(
|
||
customerId: number,
|
||
consentType: ConsentType,
|
||
data: UpdateConsentData
|
||
) {
|
||
// Prüfen ob Kunde existiert
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
});
|
||
|
||
if (!customer) {
|
||
throw new Error('Kunde nicht gefunden');
|
||
}
|
||
|
||
const now = new Date();
|
||
const updateData = {
|
||
status: data.status,
|
||
source: data.source,
|
||
documentPath: data.documentPath,
|
||
version: data.version,
|
||
ipAddress: data.ipAddress,
|
||
grantedAt: data.status === 'GRANTED' ? now : undefined,
|
||
withdrawnAt: data.status === 'WITHDRAWN' ? now : undefined,
|
||
};
|
||
|
||
const result = await prisma.customerConsent.upsert({
|
||
where: {
|
||
customerId_consentType: { customerId, consentType },
|
||
},
|
||
update: updateData,
|
||
create: {
|
||
customerId,
|
||
consentType,
|
||
...updateData,
|
||
createdBy: data.createdBy,
|
||
},
|
||
});
|
||
|
||
// Bei Widerruf: Datenschutz-PDF löschen wenn keine Einwilligung mehr besteht
|
||
if (data.status === 'WITHDRAWN') {
|
||
await deletePrivacyPdfOnWithdraw(customerId);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Holt die Historie einer Einwilligung (aus Audit-Logs)
|
||
*/
|
||
export async function getConsentHistory(customerId: number, consentType: ConsentType) {
|
||
// Aus Audit-Logs die Änderungen dieser Einwilligung abrufen
|
||
const logs = await prisma.auditLog.findMany({
|
||
where: {
|
||
resourceType: 'CustomerConsent',
|
||
dataSubjectId: customerId,
|
||
changesAfter: { contains: consentType },
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 50,
|
||
});
|
||
|
||
return logs;
|
||
}
|
||
|
||
/**
|
||
* Prüft ob eine bestimmte Einwilligung erteilt wurde
|
||
*/
|
||
export async function hasConsent(customerId: number, consentType: ConsentType): Promise<boolean> {
|
||
const consent = await prisma.customerConsent.findUnique({
|
||
where: {
|
||
customerId_consentType: { customerId, consentType },
|
||
},
|
||
});
|
||
|
||
return consent?.status === 'GRANTED';
|
||
}
|
||
|
||
/**
|
||
* Prüft ob ein Kunde die DSGVO-Einwilligung erfüllt hat.
|
||
* Erfüllt = entweder privacyPolicyPath vorhanden ODER alle Online-Consents GRANTED.
|
||
*/
|
||
export async function hasFullConsent(customerId: number): Promise<{
|
||
hasConsent: boolean;
|
||
hasPaperConsent: boolean;
|
||
hasOnlineConsent: boolean;
|
||
consentDetails: { type: string; status: string }[];
|
||
consentHash: string | null;
|
||
}> {
|
||
// Prüfe ob Papier-Datenschutzerklärung vorhanden
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { privacyPolicyPath: true, consentHash: true },
|
||
});
|
||
|
||
const hasPaperConsent = !!customer?.privacyPolicyPath;
|
||
|
||
// Online-Consents prüfen
|
||
const allTypes = Object.values(ConsentType);
|
||
const consents = await prisma.customerConsent.findMany({
|
||
where: { customerId },
|
||
});
|
||
|
||
const consentMap = new Map(consents.map((c) => [c.consentType, c.status]));
|
||
const consentDetails = allTypes.map((type) => ({
|
||
type,
|
||
status: (consentMap.get(type) || 'PENDING') as string,
|
||
}));
|
||
|
||
const hasOnlineConsent = allTypes.every(
|
||
(type) => consentMap.get(type) === 'GRANTED'
|
||
);
|
||
|
||
return {
|
||
hasConsent: hasPaperConsent || hasOnlineConsent,
|
||
hasPaperConsent,
|
||
hasOnlineConsent,
|
||
consentDetails,
|
||
consentHash: customer?.consentHash || null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Widerruft alle Einwilligungen eines Kunden
|
||
*/
|
||
export async function withdrawAllConsents(customerId: number, withdrawnBy: string) {
|
||
const result = await prisma.customerConsent.updateMany({
|
||
where: {
|
||
customerId,
|
||
status: 'GRANTED',
|
||
},
|
||
data: {
|
||
status: 'WITHDRAWN',
|
||
withdrawnAt: new Date(),
|
||
},
|
||
});
|
||
|
||
// Datenschutz-PDF löschen
|
||
await deletePrivacyPdfOnWithdraw(customerId);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Löscht die Datenschutz-PDF bei Widerruf.
|
||
* Sobald auch nur eine Einwilligung widerrufen wird, ist die Gesamteinwilligung ungültig.
|
||
*/
|
||
async function deletePrivacyPdfOnWithdraw(customerId: number) {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { privacyPolicyPath: true },
|
||
});
|
||
|
||
if (customer?.privacyPolicyPath) {
|
||
try {
|
||
const filePath = path.join(process.cwd(), customer.privacyPolicyPath);
|
||
if (fs.existsSync(filePath)) {
|
||
fs.unlinkSync(filePath);
|
||
}
|
||
} catch (err) {
|
||
console.error('Fehler beim Löschen der Datenschutz-PDF:', err);
|
||
}
|
||
|
||
await prisma.customer.update({
|
||
where: { id: customerId },
|
||
data: { privacyPolicyPath: null },
|
||
});
|
||
|
||
console.log(`Datenschutz-PDF für Kunde ${customerId} gelöscht (Einwilligung widerrufen)`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Consent-Übersicht für DSGVO-Dashboard
|
||
*/
|
||
export async function getConsentOverview() {
|
||
const allConsents = await prisma.customerConsent.groupBy({
|
||
by: ['consentType', 'status'],
|
||
_count: { id: true },
|
||
});
|
||
|
||
// Gruppieren nach Typ
|
||
const overview: Record<string, { granted: number; withdrawn: number; pending: number }> = {};
|
||
|
||
for (const type of Object.values(ConsentType)) {
|
||
overview[type] = { granted: 0, withdrawn: 0, pending: 0 };
|
||
}
|
||
|
||
for (const row of allConsents) {
|
||
const type = row.consentType;
|
||
const status = row.status.toLowerCase() as 'granted' | 'withdrawn' | 'pending';
|
||
overview[type][status] = row._count.id;
|
||
}
|
||
|
||
return overview;
|
||
}
|
||
|
||
/**
|
||
* Consent-Typ Labels für UI
|
||
*/
|
||
export const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
|
||
DATA_PROCESSING: {
|
||
label: 'Datenverarbeitung',
|
||
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
|
||
},
|
||
MARKETING_EMAIL: {
|
||
label: 'Elektronisches Marketing',
|
||
description: 'Zusendung von Werbung und Angeboten über elektronische Kommunikationswege (E-Mail, Messenger etc.)',
|
||
},
|
||
MARKETING_PHONE: {
|
||
label: 'Telefonmarketing',
|
||
description: 'Kontaktaufnahme zu Werbezwecken per Telefon',
|
||
},
|
||
DATA_SHARING_PARTNER: {
|
||
label: 'Datenweitergabe',
|
||
description: 'Weitergabe von Daten an Partnerunternehmen',
|
||
},
|
||
};
|