Pentest 48.3 MEDIUM + 48.4 INFO: Rate-Limit + Token-Invalidierung beim Staff-Passwort-Reset
48.3 (Rate-Limit fehlt): POST /api/users/:id/password verlangt seit 47.3 die Eingabe des eigenen Admin-Passworts. Ohne Throttle könnte ein Angreifer mit gestohlenem JWT die Re-Auth per Brute-Force aushebeln. - Neuer staffPasswordReAuthLimiter (5 Versuche / 10 min, bucket: IP + target-user-id, skipSuccessfulRequests: true) - emit SecurityEvent RATE_LIMIT_HIT severity HIGH - Vor authenticate gemounted, damit auch unauth-Spamming begrenzt wird 48.4 (Alter Token überlebt Self-Reset): Nach erfolgreichem Setzen wird tokenInvalidatedAt des Ziel-Users auf jetzt gesetzt. Greift besonders bei Self-Reset (Admin setzt sich selbst zurück) – ein zuvor gestohlenes Token wird sofort ungültig, statt bis zum natürlichen Ablauf (15 min) brauchbar zu bleiben. Die bestehende Auth-Middleware liest tokenInvalidatedAt bereits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -241,10 +241,20 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 48.4 (INFO, 2026-06-01): Bestehende JWTs für den Ziel-User
|
||||||
|
// sofort invalidieren. Greift insbesondere bei Self-Reset (Admin setzt
|
||||||
|
// sich selbst zurück) – der gestohlene Token wird damit ungültig,
|
||||||
|
// statt noch bis zum natürlichen Ablauf brauchbar zu bleiben.
|
||||||
|
// Die Middleware-Auth liest tokenInvalidatedAt und vergleicht gegen
|
||||||
|
// den `iat`-Claim des JWT.
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt (Re-Auth bestätigt)`,
|
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt (Re-Auth bestätigt, Sessions invalidiert)`,
|
||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -82,6 +82,41 @@ export const passwordResetRateLimiter = rateLimit({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff-Password-Set-Limiter (Pentest 48.3, 2026-06-01):
|
||||||
|
* POST /api/users/:id/password verlangt seit 47.3 die Eingabe des eigenen
|
||||||
|
* Admin-Passworts (`currentPassword`). Ohne Throttle könnte ein Angreifer
|
||||||
|
* mit gestohlenem JWT die 25-Zeichen-Passwort-Policy zwar nicht erraten,
|
||||||
|
* aber kürzere/typische Admin-Passwörter (z.B. Stagings, kompromittierte
|
||||||
|
* Setups) per Brute-Force durchprobieren – und damit den Re-Auth-Fix
|
||||||
|
* komplett aushebeln.
|
||||||
|
*
|
||||||
|
* Bucket: (IP, target-user-id). Damit walked ein Angreifer pro Opfer
|
||||||
|
* langsam und kann nicht mit einem stolen-token gegen alle Staff-User
|
||||||
|
* parallel anrennen. `skipSuccessfulRequests: true`, weil legitime
|
||||||
|
* Passwort-Resets nicht den Counter füllen sollen.
|
||||||
|
*/
|
||||||
|
export const staffPasswordReAuthLimiter = rateLimit({
|
||||||
|
windowMs: 10 * 60 * 1000, // 10 Minuten
|
||||||
|
limit: 5,
|
||||||
|
standardHeaders: 'draft-7',
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: 'Zu viele fehlgeschlagene Passwort-Set-Versuche. Bitte in 10 Minuten erneut versuchen.',
|
||||||
|
},
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
keyGenerator: (req): string => {
|
||||||
|
const ip = req.ip || 'unknown';
|
||||||
|
const targetUserId = (req.params?.id ?? '<missing>').toString();
|
||||||
|
return `${ip}|staff-pw|${targetUserId}`;
|
||||||
|
},
|
||||||
|
handler: (req, res, _next, options) => {
|
||||||
|
onLimitReached('staff-password-set', 'HIGH')(req, res);
|
||||||
|
res.status(options.statusCode).json(options.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
|
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
|
||||||
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
|
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as userController from '../controllers/user.controller.js';
|
import * as userController from '../controllers/user.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { staffPasswordReAuthLimiter } from '../middleware/rateLimit.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,8 +11,10 @@ router.post('/', authenticate, requirePermission('users:create'), userController
|
|||||||
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
|
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
|
||||||
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
|
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
|
||||||
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
|
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
|
||||||
// Passwort-Reset durch Admin – dedizierter Endpoint (Pentest Runde 12)
|
// Passwort-Reset durch Admin – dedizierter Endpoint (Pentest Runde 12).
|
||||||
router.post('/:id/password', authenticate, requirePermission('users:update'), userController.setUserPassword);
|
// 47.3 verlangt Re-Auth (currentPassword), 48.3 wirft einen Rate-Limit
|
||||||
|
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
||||||
|
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
||||||
|
|||||||
Reference in New Issue
Block a user