Pentest 2026-05-24 Pen-31-Befunde (2x MEDIUM)

31.1 Stored XSS in Vertragsfeldern:
providerName, tariffName, priceFirst12Months, priceFrom13Months,
priceAfter24Months nahmen rohe HTML-/Script-Payloads (<script>,
<svg/onload>, <img onerror>, javascript:, HTML-Entities) an und
lieferten sie 1:1 an Portal-User zurueck.

Fix: rekursiver sanitizeContractBody()-Walker im contract.controller,
strippt String-Werte ueber das bestehende stripHtml() (Tag-Strip +
URI-Schema-Block + Entity-Decode). Verträge enthalten keine legitimen
HTML-Felder, deshalb safe. Audit-Vergleich nutzt jetzt die
sanitisierte Variante, sonst Audit ↔ DB-Drift.

31.2 IDOR auf GET /api/customers/:id/stressfrei-emails (+5 weitere):
requireCustomerAccess short-circuitete auf customers:read. Portal-
User haben aber genau diese Perm im JWT (für eigene Daten) – damit
kam Portal-Kunde 1 an Adressen/Bank-Cards/Documents/Meters/
Stressfrei-Emails von Kunde 3.

Fix im Middleware: erst isCustomerPortal-Check (eigene + vertretene
IDs), DANN erst Perm-Check für Mitarbeiter. Mit einem Patch alle
sechs requireCustomerAccess-Routes dicht. Defense-in-Depth:
zusätzlicher canAccessCustomer-Call in
stressfreiEmail.getEmailsByCustomer analog zum POST-Handler.

Live-verifiziert auf dev:
- Portal-User 1 → Customer 3: alle 6 Routes 403
- XSS-Payloads in 5 Contract-Feldern → DB enthält bereinigte Werte

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:38:16 +02:00
parent 897abc7b21
commit aa0900410b
4 changed files with 103 additions and 20 deletions
+33 -5
View File
@@ -6,10 +6,33 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
import * as authorizationService from '../services/authorization.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
* eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.).
* Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die
* price*-Felder nahmen rohe HTML-Payloads an (`<script>`, `<svg onload>`)
* und lieferten sie 1:1 an Portal-User zurück. Verträge enthalten KEINE
* HTML-Felder (Richtige HTML-Texte liegen in AppSettings), deshalb ist
* Strip safe.
*/
function sanitizeContractBody(body: unknown): unknown {
if (body === null || body === undefined) return body;
if (typeof body === 'string') return stripHtml(body);
if (Array.isArray(body)) return body.map(sanitizeContractBody);
if (typeof body === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
out[k] = sanitizeContractBody(v);
}
return out;
}
return body;
}
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try {
const { customerId, type, status, search, page, limit, tree } = req.query;
@@ -122,7 +145,8 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
return;
}
const contract = await contractService.createContract(req.body);
const sanitizedBody = sanitizeContractBody(body);
const contract = await contractService.createContract(sanitizedBody as any);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
@@ -149,7 +173,9 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
});
const contract = await contractService.updateContract(contractId, req.body);
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
const sanitizedBody = sanitizeContractBody(req.body);
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Geänderte Felder ermitteln
const changes: Record<string, { von: unknown; nach: unknown }> = {};
@@ -168,8 +194,10 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
};
// Hauptfelder vergleichen
const body = req.body;
// Hauptfelder vergleichen gegen die SANITISIERTE Version, damit
// das Audit-Log die echten DB-Werte widerspiegelt, nicht den
// rohen Request-Body mit ggf. gestrippter HTML.
const body = sanitizedBody as any;
if (before) {
for (const [key, newVal] of Object.entries(body)) {
if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue;
@@ -2,11 +2,17 @@ import { Request, Response } from 'express';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
// requireCustomerAccess in der Route greift nicht ausreichend:
// Portal-User haben `customers:read` (für eigene Daten) und werden
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
// (MEDIUM 31.2) IDOR auf fremde IMAP-Konten. Hier daher der
// explizite Per-Customer-Check analog zum POST-Handler.
if (!(await canAccessCustomer(req, res, customerId))) return;
const includeInactive = req.query.includeInactive === 'true';
const emails = await stressfreiEmailService.getEmailsByCustomerId(customerId, includeInactive);
res.json({ success: true, data: emails } as ApiResponse);
+26 -13
View File
@@ -158,9 +158,34 @@ export function requireCustomerAccess(
return;
}
// WICHTIG: erst die isCustomerPortal-Prüfung, DANN erst die Perm-Prüfung.
// Portal-User bekommen `customers:read` im JWT (für eigene Daten); ohne
// den Portal-Check vorne weg short-circuited die alte Logik auf der
// Perm und ließ Portal-User auf fremde customerId zugreifen.
// Pentest 2026-05-24 (MEDIUM 31.2 IDOR auf /api/customers/:id/
// stressfrei-emails). Auch andere Routes mit dem gleichen Middleware-
// Pattern wären betroffen gewesen.
const userPermissions = req.user.permissions || [];
const isPortal = !!(req.user as any).isCustomerPortal;
const customerId = parseInt(req.params.customerId || req.params.id);
// Admins and employees can access all customers
if (isPortal) {
const allowedIds = [
req.user.customerId,
...((req.user as any).representedCustomerIds || []),
].filter(Boolean);
if (allowedIds.includes(customerId)) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Kundendaten',
});
return;
}
// Mitarbeiter/Admin: customers:read oder customers:update reicht
if (
userPermissions.includes('customers:read') ||
userPermissions.includes('customers:update')
@@ -169,18 +194,6 @@ export function requireCustomerAccess(
return;
}
// Customers can only access their own data + represented customers
const customerId = parseInt(req.params.customerId || req.params.id);
const allowedIds = [
req.user.customerId,
...((req.user as any).representedCustomerIds || []),
].filter(Boolean);
if (allowedIds.includes(customerId)) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Kundendaten',