Files
opencrm/backend/src/services/consent.service.ts
T
duffyduck adc3b70492 Pentest 2026-05-20 MEDIUM+LOW Follow-ups
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>
2026-05-20 01:13:19 +02:00

286 lines
7.6 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 { 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',
},
};