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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user