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>
This commit is contained in:
2026-05-20 01:13:19 +02:00
parent bf7afdd9a6
commit adc3b70492
5 changed files with 125 additions and 9 deletions
+19 -7
View File
@@ -13,6 +13,7 @@ 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)
@@ -269,7 +270,14 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const consentType = req.params.consentType as ConsentType;
const { status, source, documentPath, version } = req.body;
// 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) {
@@ -296,9 +304,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
const consent = await consentService.updateConsent(customerId, consentType, {
status,
source: source || 'portal',
documentPath,
version,
source: 'portal',
ipAddress: req.socket.remoteAddress,
createdBy: req.user?.email || 'unknown',
});
@@ -307,7 +313,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
await logChange({
req, action: 'UPDATE', resourceType: 'CustomerConsent',
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
details: { einwilligung: consentName, status, quelle: source || 'portal' },
details: { einwilligung: consentName, status, quelle: 'portal' },
customerId,
});
@@ -814,9 +820,15 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
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: source || 'crm-backend',
notes,
source: safeSource,
notes: safeNotes as string | undefined,
});
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });