Pentest 47.1/47.2/47.3: Re-Auth bei sensiblen Operationen + Provider.name-Strip

47.3 MEDIUM (Admin-Passwort-Reset ohne Re-Auth):
POST /api/users/:id/password verlangt jetzt currentPassword im
Body. Backend prüft per bcrypt.compare gegen den Hash des
aufrufenden Admins. Frontend (UserList-Modal): zusätzliches
Passwort-Feld wird eingeblendet, sobald für einen User ein neues
Passwort gesetzt werden soll. Gestohlener JWT allein reicht damit
nicht mehr.

47.1 MEDIUM (Open Redirect / Phishing via provider.portalUrl):
Selbes Re-Auth-Pattern für Provider-Endpoints. Nur wenn die
Portal-URL-Domain WIRKLICH gewechselt wird (Host-Vergleich)
oder beim Create mit URL, ist currentPassword Pflicht. Reine
Namens-/Tarif-Edits bleiben friction-frei.
Audit-Log bekommt die Portal-URL beim Ändern explizit mitgeloggt
(Forensik bei Vorfällen). Frontend ProviderModal zeigt amber-
farbenen Bestätigungs-Banner mit Passwort-Eingabe sobald der
Host wechselt.

47.2 INFO (provider.name ohne Backend-Sanitization):
Neuer Helper stripProviderStrings in provider.service, wendet
stripHtml auf name + usernameFieldName + passwordFieldName an –
Defense-in-Depth gegen neue Renderpfade (PDF, Mail-Templates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:38:45 +02:00
parent d0d2715baa
commit 2c0166ed99
6 changed files with 193 additions and 16 deletions
+64 -5
View File
@@ -1,7 +1,34 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js';
import * as providerService from '../services/provider.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
// Pentest 47.1 (MEDIUM, 2026-06-01): Open Redirect / Phishing via
// provider.portalUrl. Bei kompromittiertem Admin-Account konnte ein
// Angreifer einen Phishing-Link auf einem real existierenden Provider
// hinterlegen jeder Portal-User mit dem Provider sah ihn dauerhaft.
// Re-Auth-Pattern analog 47.3 (Staff-Password): bei Änderung der
// portalUrl-Domain muss der Admin sein eigenes Passwort mitsenden.
async function requireCallerPasswordReAuth(req: AuthRequest, providedPassword: unknown): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
const callerId = req.user?.userId;
if (!callerId) return { ok: false, status: 401, error: 'Nicht authentifiziert' };
if (typeof providedPassword !== 'string' || providedPassword.length === 0) {
return { ok: false, status: 400, error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.' };
}
const caller = await prisma.user.findUnique({ where: { id: callerId }, select: { password: true } });
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
const ok = await bcrypt.compare(providedPassword, callerHash);
if (!caller || !ok) return { ok: false, status: 403, error: 'Eigenes Passwort ist falsch.' };
return { ok: true };
}
function hostOf(url: string | null | undefined): string | null {
if (!url) return null;
try { return new URL(url).host.toLowerCase(); } catch { return null; }
}
export async function getProviders(req: Request, res: Response): Promise<void> {
try {
@@ -50,11 +77,21 @@ export async function getProvider(req: Request, res: Response): Promise<void> {
export async function createProvider(req: Request, res: Response): Promise<void> {
try {
const provider = await providerService.createProvider(req.body);
const { currentPassword, ...providerData } = req.body || {};
// 47.1: Beim Create mit portalUrl ist Re-Auth Pflicht. Ohne portalUrl
// (rein interner Provider-Stammdatensatz) kein Zwang.
if (providerData.portalUrl) {
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
if (!reauth.ok) {
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
return;
}
}
const provider = await providerService.createProvider(providerData);
await logChange({
req, action: 'CREATE', resourceType: 'Provider',
resourceId: provider.id.toString(),
label: `Anbieter ${provider.name} angelegt`,
label: `Anbieter ${provider.name} angelegt${provider.portalUrl ? ` mit Portal-URL ${provider.portalUrl}` : ''}`,
});
res.status(201).json({ success: true, data: provider } as ApiResponse);
} catch (error) {
@@ -67,11 +104,33 @@ export async function createProvider(req: Request, res: Response): Promise<void>
export async function updateProvider(req: Request, res: Response): Promise<void> {
try {
const provider = await providerService.updateProvider(parseInt(req.params.id), req.body);
const providerId = parseInt(req.params.id);
const { currentPassword, ...providerData } = req.body || {};
// 47.1: portalUrl-Domain-Wechsel braucht Re-Auth. Wir laden den
// bisherigen Wert und vergleichen den Host nur dann ist es ein
// sensible Operation. Reine Namens-/Tarif-Edits bleiben friction-frei.
if (providerData.portalUrl !== undefined) {
const before = await providerService.getProviderById(providerId);
const oldHost = hostOf(before?.portalUrl);
const newHost = hostOf(providerData.portalUrl);
const isDomainChange = (newHost && newHost !== oldHost) || (oldHost && !newHost);
if (isDomainChange) {
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
if (!reauth.ok) {
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
return;
}
}
}
const provider = await providerService.updateProvider(providerId, providerData);
await logChange({
req, action: 'UPDATE', resourceType: 'Provider',
resourceId: provider.id.toString(),
label: `Anbieter ${provider.name} aktualisiert`,
label: providerData.portalUrl !== undefined
? `Anbieter ${provider.name} aktualisiert (Portal-URL: ${provider.portalUrl ?? 'entfernt'})`
: `Anbieter ${provider.name} aktualisiert`,
});
res.json({ success: true, data: provider } as ApiResponse);
} catch (error) {
+37 -3
View File
@@ -1,8 +1,9 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
@@ -189,11 +190,44 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
export async function setUserPassword(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
const { password } = req.body || {};
const { password, currentPassword } = req.body || {};
if (!password || typeof password !== 'string') {
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
return;
}
// Pentest 47.3 (MEDIUM, 2026-06-01): Re-Auth verpflichtend.
// Ein gestohlener Admin-JWT reichte bisher, um Staff-Passwörter
// umzuschreiben. Jetzt muss der aufrufende Admin sein eigenes
// Passwort mitsenden CSRF/Token-Klau allein reicht nicht mehr.
const authReq = req as AuthRequest;
const callerId = authReq.user?.userId;
if (!callerId) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
if (!currentPassword || typeof currentPassword !== 'string') {
res.status(400).json({
success: false,
error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.',
} as ApiResponse);
return;
}
const caller = await prisma.user.findUnique({
where: { id: callerId },
select: { password: true },
});
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
const reAuthOk = await bcrypt.compare(currentPassword, callerHash);
if (!caller || !reAuthOk) {
res.status(403).json({
success: false,
error: 'Eigenes Passwort ist falsch.',
} as ApiResponse);
return;
}
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
res.status(400).json({
@@ -210,7 +244,7 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt`,
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt (Re-Auth bestätigt)`,
});
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) {
+17 -3
View File
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { validateHttpUrl } from '../utils/url.js';
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
@@ -46,16 +47,29 @@ export async function getProviderById(id: number) {
});
}
// Pentest 47.2 (INFO, 2026-06-01): provider.name landete roh in der DB.
// Aktuell escapt React das Textnode, also kein direkter XSS aber neue
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
function stripProviderStrings<T extends { name?: string; usernameFieldName?: string; passwordFieldName?: string }>(data: T): T {
const out: any = { ...data };
if (typeof out.name === 'string') out.name = stripHtml(out.name);
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
return out;
}
export async function createProvider(data: {
name: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
}) {
const portalUrl = assertValidPortalUrl(data.portalUrl);
const clean = stripProviderStrings(data);
const portalUrl = assertValidPortalUrl(clean.portalUrl);
return prisma.provider.create({
data: {
...data,
...clean,
portalUrl,
isActive: true,
},
@@ -75,7 +89,7 @@ export async function updateProvider(
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
// damit Prisma nicht den alten Wert hält.
const updateData: typeof data = { ...data };
const updateData: typeof data = stripProviderStrings(data);
if (data.portalUrl !== undefined) {
const validated = assertValidPortalUrl(data.portalUrl);
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;