Files
opencrm/backend/src/controllers/consent-public.controller.ts
T
duffyduck 38c2d82c02 Security-Hardening Runde 9: Pentest Runde 5
KRITISCH – change-initial-portal-password ohne mustChange-Pflicht-Check:
Jeder Portal-User konnte jederzeit sein Passwort ohne Kenntnis des
alten ersetzen (XSS-/Token-Hijack-Eskalation). Endpoint war NUR für
den OTP-Erst-Login gedacht, prüfte aber das Flag nicht. Fix: Customer
laden, portalPasswordMustChange=true erzwingen, sonst 403.

NIEDRIG – consentHash leakte über GET /customers/🆔
Hash ist Pseudo-Credential für den öffentlichen Consent-Link. Jetzt
in SENSITIVE_CUSTOMER_FIELDS (sanitize.ts) → wird aus jeder customer-
Response gestrippt. Wer ihn legitim braucht, holt ihn über
/gdpr/customer/:id/consent-status.

NIEDRIG – Public consent-grant Response leakte CustomerConsent-Records:
POST /api/public/consent/:hash/grant gab volle Records inkl. ipAddress
und createdBy (Kunden-Name) zurück. Auf { granted: <count> } reduziert
– Frontend liest eh nur success.

Live-verifiziert:
- Change-Initial ohne Flag → 403; mit Flag → 200; danach Flag=false →
  erneuter Aufruf 403
- GET /customers/3 → consentHash null, portalPasswordHash null
- /gdpr/customer/3/consent-status → consentHash weiterhin sichtbar
- Public-Grant-Response: {granted: 4}, keine ipAddress/createdBy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:57:09 +02:00

176 lines
6.5 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 { Request, Response } from 'express';
import * as consentPublicService from '../services/consent-public.service.js';
import { createAuditLog } from '../services/audit.service.js';
import { CONSENT_TYPE_LABELS } from '../services/consent.service.js';
import { ConsentType } from '@prisma/client';
import { sendEmail } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
/**
* Öffentliche Consent-Seite: Kundendaten + Datenschutztext + Status
*/
export async function getConsentPage(req: Request, res: Response) {
try {
const { hash } = req.params;
const result = await consentPublicService.getCustomerByConsentHash(hash);
if (!result) {
return res.status(404).json({ success: false, error: 'Ungültiger Link' });
}
const privacyPolicyHtml = await consentPublicService.getPrivacyPolicyHtml(result.customer.id);
// Consent-Status mit Labels
const consentsWithLabels = result.consents.map((c) => ({
consentType: c.consentType,
status: c.status,
label: CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.label || c.consentType,
description: CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.description || '',
grantedAt: c.grantedAt,
}));
res.json({
success: true,
data: {
customer: {
firstName: result.customer.firstName,
lastName: result.customer.lastName,
customerNumber: result.customer.customerNumber,
},
privacyPolicyHtml,
consents: consentsWithLabels,
allGranted: consentsWithLabels.every((c) => c.status === 'GRANTED'),
},
});
} catch (error) {
console.error('Fehler beim Laden der Consent-Seite:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Alle 4 Einwilligungen erteilen (öffentlicher Link)
*/
export async function grantAllConsents(req: Request, res: Response) {
try {
const { hash } = req.params;
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
const results = await consentPublicService.grantAllConsentsPublic(hash, ipAddress);
// Audit-Log (manuell, da keine Auth-Middleware)
const customer = await consentPublicService.getCustomerByConsentHash(hash);
if (customer) {
for (const type of Object.values(ConsentType)) {
await createAuditLog({
userEmail: customer.customer.email || 'public-link',
action: 'UPDATE',
sensitivity: 'HIGH',
resourceType: 'CustomerConsent',
resourceId: `${customer.customer.id}:${type}`,
resourceLabel: `Einwilligung ${type} erteilt via Public-Link`,
endpoint: `/api/public/consent/${hash}/grant`,
httpMethod: 'POST',
ipAddress,
dataSubjectId: customer.customer.id,
legalBasis: 'DSGVO Art. 6 Abs. 1 lit. a',
});
}
}
// Bestätigungs-E-Mail senden
if (customer?.customer.email) {
try {
const systemEmail = await getSystemEmailCredentials();
if (systemEmail) {
const consentList = Object.values(ConsentType)
.map(t => CONSENT_TYPE_LABELS[t]?.label || t)
.map(label => `<li>${label}</li>`)
.join('');
const confirmationHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #16a34a;">Bestätigung Ihrer Einwilligungen</h2>
<p>Sehr geehrte(r) ${customer.customer.firstName} ${customer.customer.lastName},</p>
<p>
vielen Dank! Hiermit bestätigen wir, dass Sie am ${new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
folgenden Einwilligungen zugestimmt haben:
</p>
<ul style="color: #16a34a;">
${consentList}
</ul>
<p>
Sie können Ihre Einwilligungen jederzeit über Ihr Kundenportal 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>
`;
await sendEmail(
{
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
},
systemEmail.emailAddress,
{
to: customer.customer.email,
subject: 'Bestätigung Ihrer Datenschutz-Einwilligungen',
html: confirmationHtml,
},
{
context: 'consent-confirmation',
customerId: customer.customer.id,
}
);
}
} catch (emailError) {
// E-Mail-Fehler soll den Consent-Grant nicht blockieren
console.error('Bestätigungs-E-Mail konnte nicht gesendet werden:', emailError);
}
}
// Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
// keine internen IDs das war früher der volle CustomerConsent-Record und
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
res.json({
success: true,
data: { granted: results.length },
});
} catch (error: any) {
console.error('Fehler beim Erteilen der Einwilligungen:', error);
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
}
}
/**
* Datenschutzerklärung als PDF
*/
export async function getConsentPdf(req: Request, res: Response) {
try {
const { hash } = req.params;
const result = await consentPublicService.getCustomerByConsentHash(hash);
if (!result) {
return res.status(404).json({ success: false, error: 'Ungültiger Link' });
}
const pdfBuffer = await consentPublicService.generateConsentPdf(result.customer.id);
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 PDFs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Generieren' });
}
}