Files
opencrm/backend/src/services/consent-public.service.ts
T
duffyduck 9482424ade Pentest 57.7 MEDIUM + 57.8 MEDIUM: Consent-Hash-TTL + Zip-Slip-Härtung
57.7 (Consent-Hash ohne TTL):
- Neues Feld Customer.consentHashExpiresAt + Migration
  20260601300000_consent_hash_ttl mit IF NOT EXISTS. Bestandsdaten
  bekommen NOW()+30d als Default, damit frische Versand-Links nicht
  sofort sterben.
- TTL-Konstante CONSENT_HASH_TTL_DAYS = 30 in consent-public.service.
- getCustomerByConsentHash + grantAllConsentsPublic liefern null bzw.
  klare Fehlermeldung bei Ablauf; consentHashExpiresAt wird nicht in
  der Response durchgereicht (kein Oracle "unbekannt vs. abgelaufen").
- ensureConsentHash erneuert Hash + Frist, sobald der alte abgelaufen
  ist – Versand neuer Links bleibt friction-frei.
- consentHashExpiresAt in SENSITIVE_CUSTOMER_FIELDS (sanitize), damit
  der Standard-Customer-Endpoint kein Workflow-Info leakt.

57.8 (Zip-Slip / Zip-Bomb):
- Reject zusätzlich: leere Entry-Namen, Backslashes (Cross-OS-
  Confusion), Home-Dir-Expansion (`~`), explizite `..`-Segmente
  schon im Original-Namen (vor path.resolve).
- Zip-Slip-Check auf path.relative umgestellt – robuster als
  startsWith(prefix + sep), insbesondere bei nested Resolution.
- Zip-Bomb-Schutz: 500 MB pro Entry + 5 GB Gesamt-Uncompressed-
  Limit; bei Überschreitung Abbruch mit klarer Meldung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:13:06 +02:00

320 lines
10 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 crypto from 'crypto';
import prisma from '../lib/prisma.js';
import * as consentService from './consent.service.js';
import * as appSettingService from './appSetting.service.js';
import PDFDocument from 'pdfkit';
// Pentest 57.7 (MEDIUM, 2026-06-01): Public-Consent-Hashes hatten keine
// Ablauffrist. Ein versehentlich weitergegebener oder geleakter Link
// hätte Jahre später noch fremde Einwilligungen erteilen können
// (DSGVO-Pflicht zur Zweckbindung). 30 Tage ist der Default-Zeitraum,
// in dem ein Kunde realistisch auf den Versandlink klickt; danach muss
// ein Mitarbeiter den Link neu generieren (ensureConsentHash() erzeugt
// einen neuen Hash + neue Frist).
const CONSENT_HASH_TTL_DAYS = 30;
/**
* Kunden-Lookup per consentHash. Liefert null wenn der Hash unbekannt
* oder abgelaufen ist aus Sicht des Aufrufers identisch, damit der
* Public-Endpoint keine Unterscheidung "ungültig vs. abgelaufen" leakt.
*/
export async function getCustomerByConsentHash(hash: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: {
id: true,
firstName: true,
lastName: true,
customerNumber: true,
salutation: true,
email: true,
consentHashExpiresAt: true,
},
});
if (!customer) return null;
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
return null;
}
const consents = await consentService.getCustomerConsents(customer.id);
// consentHashExpiresAt nicht an den Client durchreichen
const { consentHashExpiresAt: _expires, ...customerWithoutExpiry } = customer;
void _expires;
return { customer: customerWithoutExpiry, consents };
}
/**
* Alle 4 Einwilligungen über den öffentlichen Link erteilen.
* Wirft bei abgelaufenem oder unbekanntem Hash mit gleicher Meldung,
* damit kein Oracle "existiert vs. abgelaufen" entsteht.
*/
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
});
if (!customer) {
throw new Error('Ungültiger Link');
}
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
throw new Error('Link ist abgelaufen. Bitte einen neuen Link anfordern.');
}
const results = [];
for (const type of Object.values(ConsentType)) {
const result = await consentService.updateConsent(customer.id, type, {
status: ConsentStatus.GRANTED,
source: 'public-link',
ipAddress,
createdBy: `${customer.firstName} ${customer.lastName} (Public-Link)`,
});
results.push(result);
}
return results;
}
/**
* consentHash generieren oder erneuern. Liefert einen bestehenden Hash
* nur zurück, wenn dessen TTL noch nicht abgelaufen ist sonst wird ein
* neuer Hash + neue Frist gesetzt (Pentest 57.7).
*/
export async function ensureConsentHash(customerId: number): Promise<string> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { consentHash: true, consentHashExpiresAt: true },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
const stillValid = customer.consentHash
&& customer.consentHashExpiresAt
&& customer.consentHashExpiresAt.getTime() > Date.now();
if (stillValid) {
return customer.consentHash!;
}
const hash = crypto.randomUUID();
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
await prisma.customer.update({
where: { id: customerId },
data: { consentHash: hash, consentHashExpiresAt: expiresAt },
});
return hash;
}
/**
* Platzhalter in Text ersetzen
*/
function replacePlaceholders(html: string, customer: {
firstName: string;
lastName: string;
customerNumber: string;
salutation?: string | null;
email?: string | null;
}): string {
return html
.replace(/\{\{vorname\}\}/gi, customer.firstName || '')
.replace(/\{\{nachname\}\}/gi, customer.lastName || '')
.replace(/\{\{kundennummer\}\}/gi, customer.customerNumber || '')
.replace(/\{\{anrede\}\}/gi, customer.salutation || '')
.replace(/\{\{email\}\}/gi, customer.email || '')
.replace(/\{\{datum\}\}/gi, new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}));
}
/**
* Datenschutzerklärung als HTML abrufen (mit Platzhaltern ersetzt)
*/
export async function getPrivacyPolicyHtml(customerId?: number): Promise<string> {
const html = await appSettingService.getSetting('privacyPolicyHtml');
if (!html) {
return '<p>Keine Datenschutzerklärung hinterlegt.</p>';
}
if (!customerId) return html;
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
lastName: true,
customerNumber: true,
salutation: true,
email: true,
},
});
if (!customer) return html;
return replacePlaceholders(html, customer);
}
/**
* HTML zu Plain-Text konvertieren (für PDF)
*/
function htmlToText(html: string): string {
return html
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, '\n$1\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<li[^>]*>(.*?)<\/li>/gi, ' • $1\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/**
* Datenschutzerklärung als PDF generieren
*/
export async function generateConsentPdf(customerId: number): Promise<Buffer> {
const html = await getPrivacyPolicyHtml(customerId);
const text = htmlToText(html);
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Titel
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
doc.moveDown(1);
// Datum
doc.fontSize(10).font('Helvetica')
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
doc.moveDown(1);
// Inhalt
doc.fontSize(11).font('Helvetica').text(text, {
align: 'left',
lineGap: 4,
});
doc.end();
});
}
/**
* Datenschutzerklärung als unterschreibbare PDF (Papierform) generieren.
* Zusätzlich zum normalen Text wird unten eine Einwilligungs-Klausel +
* ein Unterschriften-Block angefügt (Ort/Datum + Unterschrift +
* Name in Druckbuchstaben). Das fertige PDF wird ausgedruckt, vom
* Kunden unterschrieben und im Tab "Einwilligungen / Datenschutz"
* wieder hochgeladen.
*/
export async function generateSignablePrivacyPdf(customerId: number): Promise<Buffer> {
const html = await getPrivacyPolicyHtml(customerId);
const text = htmlToText(html);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true, lastName: true, customerNumber: true, companyName: true,
salutation: true,
},
});
const printedName = customer
? (customer.companyName?.trim()
? customer.companyName.trim()
: `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim())
: '';
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Titel
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
doc.moveDown(0.5);
// Kundenkopf
if (printedName) {
doc.fontSize(11).font('Helvetica-Bold').text(printedName, { align: 'center' });
}
if (customer?.customerNumber) {
doc.fontSize(10).font('Helvetica').text(`Kundennummer: ${customer.customerNumber}`, { align: 'center' });
}
doc.moveDown(0.5);
doc.fontSize(10).font('Helvetica')
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
doc.moveDown(1);
// Inhalt
doc.fontSize(11).font('Helvetica').text(text, { align: 'left', lineGap: 4 });
// Genug Platz vor dem Unterschriftenblock wenn nicht mehr genug
// Platz auf der Seite, neue Seite anfangen.
if (doc.y > doc.page.height - doc.page.margins.bottom - 220) {
doc.addPage();
} else {
doc.moveDown(2);
}
// Einwilligungsklausel
doc.fontSize(11).font('Helvetica-Bold').text('Einwilligung', { underline: false });
doc.moveDown(0.3);
doc.fontSize(10).font('Helvetica').text(
'Mit meiner Unterschrift bestätige ich, dass ich die vorstehende ' +
'Datenschutzerklärung gelesen und verstanden habe und mit der ' +
'Verarbeitung meiner personenbezogenen Daten zum Zweck der ' +
'Vertragserfüllung einverstanden bin. Diese Einwilligung kann ' +
'jederzeit für die Zukunft widerrufen werden.',
{ align: 'left', lineGap: 3 },
);
doc.moveDown(1.5);
// Unterschriftenblock: links Ort/Datum, rechts Unterschrift
const startY = doc.y;
const leftX = doc.page.margins.left;
const rightX = doc.page.width / 2 + 10;
const lineWidth = doc.page.width / 2 - doc.page.margins.left - 10;
// Linien
const lineY = startY + 35;
doc.moveTo(leftX, lineY).lineTo(leftX + lineWidth, lineY).stroke();
doc.moveTo(rightX, lineY).lineTo(rightX + lineWidth, lineY).stroke();
// Labels unter den Linien
doc.fontSize(9).font('Helvetica');
doc.text('Ort, Datum', leftX, lineY + 4, { width: lineWidth, align: 'left' });
doc.text('Unterschrift', rightX, lineY + 4, { width: lineWidth, align: 'left' });
// Zweite Zeile: Name in Druckbuchstaben (vorausgefüllt mit Kunde)
doc.moveDown(3);
const nameY = doc.y;
doc.fontSize(11).font('Helvetica');
if (printedName) {
doc.text(printedName, rightX, nameY, { width: lineWidth, align: 'left' });
}
doc.moveTo(rightX, nameY + 16).lineTo(rightX + lineWidth, nameY + 16).stroke();
doc.fontSize(9).text('Name in Druckbuchstaben', rightX, nameY + 20, { width: lineWidth, align: 'left' });
doc.end();
});
}