Files
opencrm/backend/src/controllers/gdpr.controller.ts
T
duffyduck 25681075b4 Pentest 24.6 INFO + 26.7 LOW: PENDING-Status sperren + documentPath-Validator
24.6 (Portal kann Consent auf PENDING zurücksetzen):
- gdpr.controller updateCustomerConsent prüft jetzt explizit, dass
  der Portal-User nur GRANTED oder WITHDRAWN setzen kann. PENDING
  ist nur der initiale System-Status; ein Reset darauf hätte die
  DSGVO-Auswertung verfälscht.

26.7 (documentPath ohne Validierung):
- Neuer Helper isValidDocumentPath + assertValidDocumentPath in
  utils/sanitize: nur /?uploads/<safe>, keine "..", keine
  javascript:/data:/vbscript:, kein HTML.
- consent.service.updateConsent ruft den Assert auf – Defense-in-
  Depth gegen zukünftige Caller, die documentPath aus User-Input
  durchreichen könnten.
- authorization.service.grantAuthorization analog.
- Cleanup-Skript (prisma/cleanup-xss-and-mass-assignment) entfernt
  seine lokale Kopie der Path-Validierung und nutzt den shared
  Helper – Single Source of Truth.

27.1 (Altdaten in Staging-DB):
- Cleanup-Skript läuft sowieso bei jedem Container-Start. Nina-
  Records mit "../../../etc/passwd" werden beim nächsten Restart
  genullt (oder verschwinden mit dem VM-Snapshot-Wechsel).

Live-Test isValidDocumentPath: 13/13 OK – legitime Pfade durch,
Traversal/JS-URI/HTML blockiert.

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

1181 lines
43 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 { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js';
import * as consentPublicService from '../services/consent-public.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { getPublicUrl } from '../services/auth.service.js';
import { canAccessCustomer } from '../utils/accessControl.js';
import { createAuditLog, logChange } from '../services/audit.service.js';
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
import fs from 'fs';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import * as authorizationService from '../services/authorization.service.js';
import { stripHtml } from '../utils/sanitize.js';
/**
* Kundendaten exportieren (DSGVO Art. 15)
*/
export async function exportCustomerData(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const format = (req.query.format as string) || 'json';
const data = await gdprService.exportCustomerData(customerId);
// Audit-Log für Export
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'EXPORT',
resourceType: 'GDPR',
resourceId: customerId.toString(),
resourceLabel: `Datenexport für ${data.dataSubject.name}`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
legalBasis: 'DSGVO Art. 15',
});
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader(
'Content-Disposition',
`attachment; filename="datenexport_${data.dataSubject.customerNumber}_${new Date().toISOString().split('T')[0]}.json"`
);
res.json(data);
} else {
// Für PDF würde hier PDFKit verwendet werden
res.json({ success: true, data });
}
} catch (error) {
console.error('Fehler beim Datenexport:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Datenexport',
});
}
}
/**
* Löschanfrage erstellen
*/
export async function createDeletionRequest(req: AuthRequest, res: Response) {
try {
// Pentest 56.5 (LOW, 2026-06-01): customerId muss als gültige Zahl
// aus dem Body kommen (Route hat kein :id-Segment) und der Caller
// braucht Zugriff auf den Kunden. Ohne den Check konnte jemand mit
// gdpr:delete-Permission Löschanfragen für beliebige Kunden stellen
// (Insider-Sabotage durch Portal-Vertreter ohne Vollmacht).
const bodyCustomerId = req.body?.customerId;
const customerId = typeof bodyCustomerId === 'number'
? bodyCustomerId
: parseInt(bodyCustomerId);
if (!Number.isFinite(customerId) || customerId < 1) {
res.status(400).json({ success: false, error: 'customerId fehlt oder ungültig' });
return;
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const { requestSource } = req.body;
const request = await gdprService.createDeletionRequest({
customerId,
requestSource: requestSource || 'portal',
requestedBy: req.user?.email || 'unknown',
});
await logChange({
req, action: 'CREATE', resourceType: 'DeletionRequest',
resourceId: request.id.toString(),
label: `Löschanfrage erstellt für Kunde #${customerId}`,
customerId,
});
res.status(201).json({ success: true, data: request });
} catch (error) {
console.error('Fehler beim Erstellen der Löschanfrage:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
});
}
}
/**
* Alle Löschanfragen abrufen
*/
export async function getDeletionRequests(req: AuthRequest, res: Response) {
try {
const { status, page, limit } = req.query;
const result = await gdprService.getDeletionRequests({
status: status as DeletionRequestStatus | undefined,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 20,
});
res.json({ success: true, ...result });
} catch (error) {
console.error('Fehler beim Abrufen der Löschanfragen:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Einzelne Löschanfrage abrufen
*/
export async function getDeletionRequest(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const request = await gdprService.getDeletionRequest(id);
if (!request) {
return res.status(404).json({ success: false, error: 'Löschanfrage nicht gefunden' });
}
res.json({ success: true, data: request });
} catch (error) {
console.error('Fehler beim Abrufen der Löschanfrage:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Löschanfrage bearbeiten
*/
export async function processDeletionRequest(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const { action, retentionReason } = req.body;
if (!['complete', 'partial', 'reject'].includes(action)) {
return res.status(400).json({
success: false,
error: 'Ungültige Aktion. Erlaubt: complete, partial, reject',
});
}
const result = await gdprService.processDeletionRequest(id, {
processedBy: req.user?.email || 'unknown',
action,
retentionReason,
});
// Audit-Log für Löschung
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'ANONYMIZE',
resourceType: 'GDPR',
resourceId: id.toString(),
resourceLabel: `Löschanfrage ${action}`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: result.customerId,
legalBasis: 'DSGVO Art. 17',
});
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Bearbeiten',
});
}
}
/**
* Löschnachweis-PDF herunterladen
*/
export async function getDeletionProof(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const request = await gdprService.getDeletionRequest(id);
if (!request) {
return res.status(404).json({ success: false, error: 'Löschanfrage nicht gefunden' });
}
if (!request.proofDocument) {
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
}
// Path-Traversal-Schutz: proofDocument aus der DB darf nur unter uploads/ liegen
const uploadsDir = path.resolve(process.cwd(), 'uploads');
const filepath = path.resolve(uploadsDir, request.proofDocument);
if (!filepath.startsWith(uploadsDir + path.sep)) {
return res.status(400).json({ success: false, error: 'Ungültiger Dateipfad' });
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
}
res.download(filepath);
} catch (error) {
console.error('Fehler beim Download des Löschnachweises:', error);
res.status(500).json({ success: false, error: 'Fehler beim Download' });
}
}
/**
* DSGVO-Dashboard Statistiken
*/
export async function getDashboardStats(req: AuthRequest, res: Response) {
try {
const stats = await gdprService.getGDPRDashboardStats();
res.json({ success: true, data: stats });
} catch (error) {
console.error('Fehler beim Abrufen der Dashboard-Statistiken:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
// ==================== CONSENT ENDPOINTS ====================
/**
* Einwilligungen eines Kunden abrufen
*/
export async function getCustomerConsents(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const consents = await consentService.getCustomerConsents(customerId);
// Labels hinzufügen
const consentsWithLabels = consents.map((c) => ({
...c,
label: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.label,
description: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.description,
}));
res.json({ success: true, data: consentsWithLabels });
} catch (error) {
console.error('Fehler beim Abrufen der Einwilligungen:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Consent-Status prüfen (hat der Kunde vollständig zugestimmt?)
*/
export async function checkConsentStatus(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const result = await consentService.hasFullConsent(customerId);
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Consent-Check:', error);
res.status(500).json({ success: false, error: 'Fehler beim Consent-Check' });
}
}
/**
* Einwilligung aktualisieren (nur Kundenportal-Benutzer!)
*/
export async function updateCustomerConsent(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const consentType = req.params.consentType as ConsentType;
// BEWUSST nur `status` aus dem Body übernehmen. `source`, `documentPath`
// und `version` darf der Portal-User NICHT setzen Pentest 2026-05-20
// (MEDIUM): "ADMIN_OVERRIDE" als source bzw. "<script>" als version
// landeten vorher ungefiltert in der DB. source ist für diesen
// Endpoint immer 'portal'; documentPath wird ausschließlich vom
// Auth-Upload-Endpoint server-seitig gesetzt; version pflegt das CRM
// (falls überhaupt) später nach.
const { status } = req.body;
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
if (!(req.user as any)?.isCustomerPortal) {
return res.status(403).json({
success: false,
error: 'Nur Kunden können Einwilligungen ändern',
});
}
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
// widerrufene Vollmachten hatten vorher noch Zugriff)
if (!(await canAccessCustomer(req, res, customerId))) return;
if (!Object.values(ConsentType).includes(consentType)) {
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
}
// Pentest 24.6 (INFO, 2026-06-02): Portal-User durfte `PENDING`
// mitschicken und damit den Consent-Status auf den initialen System-
// Status zurücksetzen. PENDING ist nur intern (Default beim
// Customer-Anlegen); Portal darf nur GRANTED oder WITHDRAWN setzen.
// Verfälschte sonst die DSGVO-Auswertung.
if (status !== 'GRANTED' && status !== 'WITHDRAWN') {
return res.status(400).json({
success: false,
error: 'Portal-Einwilligungen dürfen nur auf GRANTED oder WITHDRAWN gesetzt werden.',
});
}
const consentLabels: Record<string, string> = {
DATA_PROCESSING: 'Datenverarbeitung',
MARKETING_EMAIL: 'E-Mail-Marketing',
MARKETING_PHONE: 'Telefonmarketing',
DATA_SHARING_PARTNER: 'Datenweitergabe',
};
const consent = await consentService.updateConsent(customerId, consentType, {
status,
source: 'portal',
ipAddress: req.socket.remoteAddress,
createdBy: req.user?.email || 'unknown',
});
const consentName = consentLabels[consentType] || consentType;
await logChange({
req, action: 'UPDATE', resourceType: 'CustomerConsent',
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
details: { einwilligung: consentName, status, quelle: 'portal' },
customerId,
});
res.json({ success: true, data: consent });
} catch (error) {
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
});
}
}
/**
* Consent-Übersicht für Dashboard
*/
export async function getConsentOverview(req: AuthRequest, res: Response) {
try {
const overview = await consentService.getConsentOverview();
// Labels hinzufügen
const overviewWithLabels = Object.entries(overview).map(([type, stats]) => ({
type,
label: consentService.CONSENT_TYPE_LABELS[type as ConsentType]?.label,
description: consentService.CONSENT_TYPE_LABELS[type as ConsentType]?.description,
...stats,
}));
res.json({ success: true, data: overviewWithLabels });
} catch (error) {
console.error('Fehler beim Abrufen der Consent-Übersicht:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
// ==================== PRIVACY POLICY ENDPOINTS ====================
/**
* Datenschutzerklärung abrufen (HTML)
*/
export async function getPrivacyPolicy(req: AuthRequest, res: Response) {
try {
const html = await appSettingService.getSetting('privacyPolicyHtml');
res.json({ success: true, data: { html: html || '' } });
} catch (error) {
console.error('Fehler beim Abrufen der Datenschutzerklärung:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Datenschutzerklärung speichern (HTML)
*/
export async function updatePrivacyPolicy(req: AuthRequest, res: Response) {
try {
const { html } = req.body;
if (typeof html !== 'string') {
return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
}
await appSettingService.setSetting('privacyPolicyHtml', html);
await logChange({
req, action: 'UPDATE', resourceType: 'PrivacyPolicy',
label: `Datenschutzerklärung aktualisiert`,
});
res.json({ success: true, message: 'Datenschutzerklärung gespeichert' });
} catch (error) {
console.error('Fehler beim Speichern der Datenschutzerklärung:', error);
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
/**
* Vollmacht-Vorlage abrufen (HTML)
*/
export async function getAuthorizationTemplate(req: AuthRequest, res: Response) {
try {
const html = await appSettingService.getSetting('authorizationTemplateHtml');
res.json({ success: true, data: { html: html || '' } });
} catch (error) {
console.error('Fehler beim Abrufen der Vollmacht-Vorlage:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Vollmacht-Vorlage speichern (HTML)
*/
export async function updateAuthorizationTemplate(req: AuthRequest, res: Response) {
try {
const { html } = req.body;
if (typeof html !== 'string') {
return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
}
await appSettingService.setSetting('authorizationTemplateHtml', html);
await logChange({
req, action: 'UPDATE', resourceType: 'AuthorizationTemplate',
label: `Vollmacht-Vorlage aktualisiert`,
});
res.json({ success: true, message: 'Vollmacht-Vorlage gespeichert' });
} catch (error) {
console.error('Fehler beim Speichern der Vollmacht-Vorlage:', error);
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
// ==================== IMPRESSUM & WEBSITE-DATENSCHUTZ ====================
export async function getImprint(req: AuthRequest, res: Response) {
try {
const html = await appSettingService.getSetting('imprintHtml');
res.json({ success: true, data: { html: html || '' } });
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
export async function updateImprint(req: AuthRequest, res: Response) {
try {
const { html } = req.body;
if (typeof html !== 'string') return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
await appSettingService.setSetting('imprintHtml', html);
await logChange({ req, action: 'UPDATE', resourceType: 'AppSetting', label: 'Impressum aktualisiert' });
res.json({ success: true, message: 'Impressum gespeichert' });
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
export async function getWebsitePrivacyPolicy(req: AuthRequest, res: Response) {
try {
const html = await appSettingService.getSetting('websitePrivacyPolicyHtml');
res.json({ success: true, data: { html: html || '' } });
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
export async function updateWebsitePrivacyPolicy(req: AuthRequest, res: Response) {
try {
const { html } = req.body;
if (typeof html !== 'string') return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
await appSettingService.setSetting('websitePrivacyPolicyHtml', html);
await logChange({ req, action: 'UPDATE', resourceType: 'AppSetting', label: 'Website-Datenschutzerklärung aktualisiert' });
res.json({ success: true, message: 'Website-Datenschutzerklärung gespeichert' });
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
// ==================== SEND CONSENT LINK ====================
/**
* Consent-Link an Kunden senden
*/
// ==================== PORTAL ENDPOINTS ====================
/**
* Portal: Eigene Datenschutzseite (Privacy Policy + Consent-Status)
*/
export async function getMyPrivacy(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const customerId = user.customerId;
const [privacyPolicyHtml, consents] = await Promise.all([
consentPublicService.getPrivacyPolicyHtml(customerId),
consentService.getCustomerConsents(customerId),
]);
const consentsWithLabels = consents.map((c) => ({
...c,
label: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.label,
description: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.description,
}));
res.json({
success: true,
data: {
privacyPolicyHtml,
consents: consentsWithLabels,
},
});
} catch (error) {
console.error('Fehler beim Laden der Portal-Datenschutzseite:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Portal: Datenschutzerklärung als PDF
*/
export async function getMyPrivacyPdf(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const pdfBuffer = await consentPublicService.generateConsentPdf(user.customerId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="datenschutzerklaerung.pdf"');
res.send(pdfBuffer);
} catch (error) {
console.error('Fehler beim Generieren des Portal-PDFs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Generieren' });
}
}
/**
* Portal: Eigenen Consent-Status prüfen
*/
export async function getMyConsentStatus(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const result = await consentService.hasFullConsent(user.customerId);
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Consent-Status:', error);
res.status(500).json({ success: false, error: 'Fehler beim Consent-Status' });
}
}
export async function sendConsentLink(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const { channel } = req.body; // 'email', 'whatsapp', 'telegram', 'signal'
// ConsentHash sicherstellen
const hash = await consentPublicService.ensureConsentHash(customerId);
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
// getPublicUrl nimmt zuerst die admin-konfigurierte AppSetting
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
const baseUrl = await getPublicUrl();
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
// Bei E-Mail: tatsächlich senden
if (channel === 'email') {
// Kunde laden
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { id: true, firstName: true, lastName: true, email: true },
});
if (!customer?.email) {
return res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
});
}
// System-E-Mail-Credentials vom aktiven Provider holen
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen eine System-E-Mail-Adresse und Passwort hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
// E-Mail zusammenstellen
const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Datenschutzerklärung Ihre Zustimmung</h2>
<p>Sehr geehrte(r) ${customer.firstName} ${customer.lastName},</p>
<p>
um Sie optimal beraten und betreuen zu können, benötigen wir Ihre Zustimmung zu unserer Datenschutzerklärung.
</p>
<p>
Bitte klicken Sie auf den folgenden Button, um unsere Datenschutzerklärung einzusehen und Ihre Einwilligung zu erteilen:
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${consentUrl}"
style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Datenschutzerklärung ansehen &amp; zustimmen
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie auch diesen Link in Ihren Browser kopieren:<br>
<a href="${consentUrl}" style="color: #2563eb; word-break: break-all;">${consentUrl}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Hacker-Net Telekommunikation Stefan Hacker<br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
`;
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: customer.email,
subject: 'Datenschutzerklärung Bitte um Ihre Zustimmung',
html: emailHtml,
}, {
context: 'consent-link',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
// Audit-Log
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'READ',
resourceType: 'CustomerConsent',
resourceId: customerId.toString(),
resourceLabel: `Consent-Link gesendet (${channel})`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
legalBasis: 'DSGVO Art. 6 Abs. 1a',
});
res.json({
success: true,
data: {
url: consentUrl,
channel,
hash,
},
});
} catch (error) {
console.error('Fehler beim Senden des Consent-Links:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
// ==================== VOLLMACHTEN ====================
/**
* Vollmacht-Anfrage an Kunden senden (per E-Mail, WhatsApp, Telegram, Signal)
*/
export async function sendAuthorizationRequest(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const { channel } = req.body;
// Kunde (Vollmachtgeber) laden
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { id: true, firstName: true, lastName: true, email: true },
});
// Vertreter (Bevollmächtigter) laden
const representative = await prisma.customer.findUnique({
where: { id: representativeId },
select: { id: true, firstName: true, lastName: true },
});
if (!customer || !representative) {
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
}
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
// getPublicUrl nimmt zuerst die admin-konfigurierte AppSetting
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
const baseUrl = await getPublicUrl();
const portalUrl = `${baseUrl}/privacy`;
// E-Mail senden
if (channel === 'email') {
if (!customer.email) {
return res.status(400).json({ success: false, error: 'Kunde hat keine E-Mail-Adresse hinterlegt' });
}
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen eine System-E-Mail-Adresse und Passwort hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Vollmacht Ihre Zustimmung erforderlich</h2>
<p>Sehr geehrte(r) ${customer.firstName} ${customer.lastName},</p>
<p>
<strong>${representative.firstName} ${representative.lastName}</strong> möchte als Ihr Vertreter Zugriff auf Ihre Vertragsdaten erhalten.
</p>
<p>
Damit dies möglich ist, benötigen wir Ihre Vollmacht. Sie können diese bequem über unser Kundenportal erteilen:
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${portalUrl}"
style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Vollmacht im Portal erteilen
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie auch diesen Link in Ihren Browser kopieren:<br>
<a href="${portalUrl}" style="color: #2563eb; word-break: break-all;">${portalUrl}</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Sie können die Vollmacht jederzeit im Portal unter "Datenschutz" widerrufen.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Hacker-Net Telekommunikation Stefan Hacker<br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
`;
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: customer.email,
subject: `Vollmacht für ${representative.firstName} ${representative.lastName} Bitte um Ihre Zustimmung`,
html: emailHtml,
}, {
context: 'authorization-request',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
// Messaging-Text für WhatsApp/Telegram/Signal
const messageText = `Hallo ${customer.firstName}, ${representative.firstName} ${representative.lastName} möchte als Ihr Vertreter Zugriff auf Ihre Vertragsdaten. Bitte erteilen Sie die Vollmacht im Portal: ${portalUrl}`;
res.json({
success: true,
data: { channel, portalUrl, messageText },
});
} catch (error) {
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
/**
* Vollmachten für einen Kunden abrufen (Admin-Ansicht)
*/
export async function getAuthorizations(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
await authorizationService.ensureAuthorizationEntries(customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
res.json({ success: true, data: authorizations });
} catch (error) {
console.error('Fehler beim Laden der Vollmachten:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden der Vollmachten' });
}
}
/**
* Vollmacht erteilen (Admin: z.B. Papier-Upload)
*/
export async function grantAuthorization(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const { source, notes } = req.body;
// Whitelist erzwingen, sonst landen Phantasie-Werte wie "ADMIN_OVERRIDE"
// oder `<script>` in der DB (Pentest 2026-05-20). notes wird durch
// stripHtml geschickt (Plain-Text-Feld).
const safeSource = consentService.sanitizeConsentSource(source, 'crm-backend');
const safeNotes = typeof notes === 'string' ? stripHtml(notes) : notes;
const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
source: safeSource,
notes: safeNotes as string | undefined,
});
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
const repName = rep ? `${rep.firstName} ${rep.lastName}` : `#${representativeId}`;
await logChange({
req, action: 'UPDATE', resourceType: 'Authorization',
resourceId: auth.id.toString(),
label: `Vollmacht für ${repName} erteilt (Admin)`,
customerId,
});
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Erteilen der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erteilen der Vollmacht',
});
}
}
/**
* Vollmacht widerrufen
*/
export async function withdrawAuthorization(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const auth = await authorizationService.withdrawAuthorization(customerId, representativeId);
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
const repName = rep ? `${rep.firstName} ${rep.lastName}` : `#${representativeId}`;
await logChange({
req, action: 'UPDATE', resourceType: 'Authorization',
resourceId: auth.id.toString(),
label: `Vollmacht für ${repName} widerrufen (Admin)`,
customerId,
});
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Widerrufen der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Widerrufen',
});
}
}
/**
* Vollmacht-Dokument hochladen (PDF)
*/
export async function uploadAuthorizationDocument(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
if (!req.file) {
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
}
// Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten
// MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf"
// hochladen. Wir verlangen:
// 1) Magic-Bytes "%PDF-" am Anfang
// 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende)
// 3) keinen Shebang ("#!") und kein "<script"/"<?php" in den
// ersten 4 KB (Pentest 28.3 Partial: "%PDF-1.4\n#!/bin/bash"
// passierte die reine Magic-Byte-Prüfung).
// Wer trotzdem eine PDF mit eingebettetem JS hochlädt, bekommt das
// hier nicht erkannt aber das ist Adobe-Acrobat-Risiko und nicht
// mehr ein CRM-Backend-Bug. Hier geht's um simple File-Type-Spoofs.
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
try {
const stat = fs.statSync(req.file.path);
const fd = fs.openSync(req.file.path, 'r');
// Header
const head = Buffer.alloc(5);
fs.readSync(fd, head, 0, 5, 0);
if (!head.equals(PDF_MAGIC)) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
});
}
// Erste 4 KB scannen auf verbotene Marker (Shell-Script,
// HTML/PHP-Payload). Ein echtes PDF enthält am Anfang nur
// Binärdaten + ein paar ASCII-Marker, "#!" / "<script" sind
// klare Spoof-Indikatoren.
const headSize = Math.min(stat.size, 4096);
const headBuf = Buffer.alloc(headSize);
fs.readSync(fd, headBuf, 0, headSize, 0);
const headStr = headBuf.toString('latin1').toLowerCase();
const forbidden = ['#!/', '<script', '<?php', '<%', 'mz']; // last = PE/Windows exe
const hit = forbidden.find((m) => headStr.includes(m));
if (hit) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: `Datei enthält verdächtiges Payload-Pattern ("${hit}").`,
});
}
// EOF-Marker in den letzten 1 KB. Strikt PDF/A wäre genau am
// Dateiende, aber viele Tools schreiben Whitespace/Newlines
// nach %%EOF, deshalb prüfen wir das letzte KB.
if (stat.size >= 5) {
const tailSize = Math.min(stat.size, 1024);
const tailBuf = Buffer.alloc(tailSize);
fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
if (!tailBuf.toString('latin1').includes('%%EOF')) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (EOF-Marker fehlt).',
});
}
}
fs.closeSync(fd);
} catch (_e) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Hochgeladene Datei konnte nicht gelesen werden.',
});
}
const documentPath = `/uploads/authorizations/${req.file.filename}`;
const auth = await authorizationService.updateAuthorizationDocument(
customerId,
representativeId,
documentPath
);
await logChange({
req, action: 'CREATE', resourceType: 'AuthorizationDocument',
resourceId: auth.id.toString(),
label: `Vollmacht-PDF hochgeladen für Vertreter #${representativeId}`,
customerId,
});
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Upload',
});
}
}
/**
* Vollmacht-Dokument löschen
*/
export async function deleteAuthorizationDocument(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const auth = await authorizationService.deleteAuthorizationDocument(customerId, representativeId);
await logChange({
req, action: 'DELETE', resourceType: 'AuthorizationDocument',
resourceId: auth.id.toString(),
label: `Vollmacht-PDF gelöscht für Vertreter #${representativeId}`,
customerId,
});
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
});
}
}
/**
* Portal: Eigene Vollmachten abrufen (welche Vertreter dürfen meine Daten sehen?)
*/
export async function getMyAuthorizations(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
await authorizationService.ensureAuthorizationEntries(user.customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(user.customerId);
res.json({ success: true, data: authorizations });
} catch (error) {
console.error('Fehler beim Laden der eigenen Vollmachten:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Portal: Vollmacht erteilen/widerrufen
*/
export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const representativeId = parseInt(req.params.representativeId);
const { grant } = req.body;
// Validierungen:
// 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
if (representativeId === user.customerId) {
return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
}
// 2) Existenz + aktives Vertreter-Verhältnis in EINEM Lookup prüfen.
// Beide Fälle (representative existiert nicht / keine aktive Beziehung)
// geben identisch 403, damit ein Angreifer keine Customer-IDs aus der
// DB enumerieren kann (kein 404-vs-403-Disclosure).
const relation = await prisma.customerRepresentative.findFirst({
where: { customerId: user.customerId, representativeId, isActive: true },
include: { representative: { select: { firstName: true, lastName: true } } },
});
if (!relation) {
return res.status(403).json({
success: false,
error: 'Kein Vertreter-Verhältnis Vollmacht nicht erlaubt',
});
}
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
let auth;
if (grant) {
auth = await authorizationService.grantAuthorization(user.customerId, representativeId, {
source: 'portal',
});
await logChange({ req, action: 'UPDATE', resourceType: 'RepresentativeAuthorization', label: `Vollmacht für ${repName} erteilt`, details: { status: 'erteilt', vertreter: repName, quelle: 'portal' }, customerId: user.customerId });
} else {
auth = await authorizationService.withdrawAuthorization(user.customerId, representativeId);
await logChange({ req, action: 'UPDATE', resourceType: 'RepresentativeAuthorization', label: `Vollmacht für ${repName} widerrufen`, details: { status: 'widerrufen', vertreter: repName, quelle: 'portal' }, customerId: user.customerId });
}
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Ändern der Vollmacht:', error);
// Generische Fehlermeldung Prisma-Errors enthalten Pfad/Schema und
// sollten nicht an Endkunden geleakt werden.
res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
}
}
/**
* Portal: Prüfe Vollmacht-Status für alle vertretenen Kunden
*/
export async function getMyAuthorizationStatus(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
// IDs der Kunden die dieser Vertreter vertritt
const representedIds: number[] = user.representedCustomerIds || [];
// Für jeden vertretenen Kunden prüfen ob Vollmacht erteilt
const statuses: { customerId: number; hasAuthorization: boolean }[] = [];
for (const custId of representedIds) {
const has = await authorizationService.hasAuthorization(custId, user.customerId);
statuses.push({ customerId: custId, hasAuthorization: has });
}
res.json({ success: true, data: statuses });
} catch (error) {
console.error('Fehler beim Vollmacht-Status:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Unterschreibbare Datenschutzerklärung (Papierform) als PDF generieren.
* Verwendung: Mitarbeiter klickt im Tab "Einwilligungen / Datenschutz"
* auf "Vorlage zum Unterschreiben", PDF kommt mit personalisiertem
* Kopf + Unterschriftsfeld zum Ausdrucken zurück.
*
* GET /api/gdpr/customer/:customerId/privacy-pdf
*/
export async function getSignablePrivacyPdf(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId, 10);
if (!Number.isFinite(customerId) || customerId < 1) {
return res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' });
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const pdf = await consentPublicService.generateSignablePrivacyPdf(customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { customerNumber: true },
});
const filename = `datenschutzerklaerung-${customer?.customerNumber || customerId}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(pdf);
} catch (error) {
console.error('Fehler bei Datenschutz-PDF:', error);
res.status(500).json({ success: false, error: 'Fehler beim Generieren der PDF' });
}
}