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:
@@ -77,7 +77,52 @@ function stripHtmlString(s: string): string {
|
||||
return s
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '');
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||
.replace(/(?:javascript|data|vbscript)\s*:/gi, 'blocked:');
|
||||
}
|
||||
|
||||
// Legitime CustomerConsent.source-Werte. Alles andere wird beim Cleanup
|
||||
// auf 'unknown' normalisiert. Pentest 2026-05-20.
|
||||
const ALLOWED_CONSENT_SOURCES = new Set([
|
||||
'portal',
|
||||
'public-link',
|
||||
'telefon',
|
||||
'papier',
|
||||
'email',
|
||||
'crm-backend',
|
||||
]);
|
||||
|
||||
async function cleanupConsents() {
|
||||
// version + documentPath: HTML strippen (waren ohne Validierung).
|
||||
// source: Whitelist erzwingen.
|
||||
let versionStripped = 0;
|
||||
let pathStripped = 0;
|
||||
let sourceFixed = 0;
|
||||
const consents = await prisma.customerConsent.findMany({
|
||||
select: { id: true, source: true, documentPath: true, version: true },
|
||||
});
|
||||
for (const c of consents) {
|
||||
const data: Record<string, string | null> = {};
|
||||
if (c.version && c.version !== stripHtmlString(c.version)) {
|
||||
data.version = stripHtmlString(c.version);
|
||||
versionStripped++;
|
||||
}
|
||||
if (c.documentPath && c.documentPath !== stripHtmlString(c.documentPath)) {
|
||||
data.documentPath = stripHtmlString(c.documentPath);
|
||||
pathStripped++;
|
||||
}
|
||||
if (c.source && !ALLOWED_CONSENT_SOURCES.has(c.source)) {
|
||||
data.source = 'unknown';
|
||||
sourceFixed++;
|
||||
}
|
||||
if (Object.keys(data).length > 0) {
|
||||
await prisma.customerConsent.update({ where: { id: c.id }, data });
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` → Consent bereinigt: version-stripped=${versionStripped}, ` +
|
||||
`documentPath-stripped=${pathStripped}, source-whitelist=${sourceFixed}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupAppSettings() {
|
||||
@@ -182,6 +227,7 @@ async function main() {
|
||||
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
|
||||
await cleanupXss();
|
||||
await cleanupAppSettings();
|
||||
await cleanupConsents();
|
||||
await findOrPurgePentestRecords();
|
||||
console.log('=== Fertig. ===');
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -3,6 +3,24 @@ 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;
|
||||
|
||||
@@ -239,13 +239,25 @@ const USER_CREATE_FIELDS = [
|
||||
* Defense-in-Depth speichert lieber gleich nichts Bösartiges.
|
||||
* Pentest Runde 11 (2026-05-18), M2: <script>alert(1)</script> in
|
||||
* companyName landete vorher ungefiltert in der DB.
|
||||
*
|
||||
* Pentest 2026-05-20 (LOW): zusätzlich werden Skript-URI-Schemata
|
||||
* unschädlich gemacht (`javascript:`, `data:`, `vbscript:`). Plain-Text-
|
||||
* Felder enthalten legitime URLs ohnehin selten; ein gespeicherter
|
||||
* `javascript:alert(1)` würde ansonsten in einem `<a href={value}>`
|
||||
* sofort feuern.
|
||||
*/
|
||||
const DANGEROUS_URI_SCHEMES = /(?:javascript|data|vbscript)\s*:/gi;
|
||||
|
||||
export function stripHtml(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '');
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||
// Schema durch harmloses Token ersetzen – komplette Entfernung
|
||||
// könnte legitimen Text wie "Java Script :)" verändern, dieses
|
||||
// Pattern matcht nur das Schema selbst.
|
||||
.replace(DANGEROUS_URI_SCHEMES, 'blocked:');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,6 +120,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
||||
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
||||
|
||||
- [x] **🛡️ Pentest 2026-05-20 MEDIUM+LOW: Consent + URI-Sanitization**
|
||||
- **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 noch `status` aus Body, source server-seitig hardcoded
|
||||
auf `'portal'`, documentPath/version bleiben NULL (werden vom
|
||||
dedizierten Authorization-Upload-Endpoint server-seitig gesetzt).
|
||||
- Whitelist für `CustomerConsent.source` ergänzt
|
||||
(`portal | public-link | telefon | papier | email | crm-backend`).
|
||||
`grantAuthorization` (Admin) erzwingt sie ebenfalls; `notes`
|
||||
läuft jetzt durch `stripHtml`.
|
||||
- **LOW javascript:-URI**: `stripHtml()` filtert jetzt zusätzlich
|
||||
`javascript:`, `data:`, `vbscript:` – ersetzt durch `blocked:`,
|
||||
damit `<a href={companyName}>` nichts feuert. Verträglich mit
|
||||
legitimem Text, der nicht "javascript:" enthält.
|
||||
- Cleanup-Skript erweitert um `cleanupConsents()`:
|
||||
Whitelist-Reset für source, stripHtml für version/documentPath
|
||||
– idempotent, läuft beim Container-Start automatisch.
|
||||
- **Live-verifiziert** auf dev:
|
||||
- PUT mit `{status:"GRANTED",source:"ADMIN_OVERRIDE",
|
||||
version:"<script>",documentPath:"../../etc/passwd"}` →
|
||||
DB hat `source=portal, documentPath=NULL, version=NULL`.
|
||||
- companyName `javascript:alert(1)` → `blocked:alert(1)`.
|
||||
- companyName `<a href=data:text/html,<script>...>Click</a>` → `Click`.
|
||||
- Cleanup auf dirty DB: source `ADMIN_OVERRIDE` → `unknown`,
|
||||
documentPath/version gesäubert.
|
||||
|
||||
- [x] **🚨 Pentest 2026-05-20 KRITISCH: Backup-Restore ohne Confirm-Body**
|
||||
- `POST /api/settings/backup/:name/restore` startete bei leerem
|
||||
Body sofort den destruktiven Restore. Im Unterschied zu
|
||||
|
||||
Reference in New Issue
Block a user