fix(security): harden 2FA setup, OIDC default role, and rate-limit fallback (v0.17.3-beta)
Address the actionable findings from an adversarial review: - 2FA enrollment now requires the current password (/totp/setup), so a hijacked session or XSS can't enable TOTP and lock the real user out. SSO-only accounts (no local password) are told to set one first. Frontend collects the password before starting setup. - OIDC config rejects default_role='admin': auto-provisioning unmatched users straight to admin is almost never intended — admin must come from an explicit group → role mapping. - Rate limiter no longer fails fully open on a Redis outage. Redis calls are bounded by an 800ms timeout (the client uses maxRetriesPerRequest:null, so commands would otherwise hang, not throw), and on failure a per-process in-memory sliding-window fallback keeps /login and other limited endpoints throttled. Verified with Redis stopped: attempts throttle (429) instead of hanging or passing unlimited. Findings intentionally not actioned: the "API-token auth is fire-and-forget" report was a false positive (Express awaits next(), which is called inside the promise), and the webhook DB-pool and JWT-revocation items are either overstated or a separate architectural decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b3ba2b98dc
commit
28701cfc67
@@ -99,8 +99,24 @@ router.post('/totp/verify', rateLimitRedis({ windowSec: 60, max: 5, keyPrefix: '
|
||||
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// Generate a new TOTP secret + QR code for the current user (does not enable yet)
|
||||
// Generate a new TOTP secret + QR code for the current user (does not enable yet).
|
||||
// Requires the current password so a hijacked session can't enroll 2FA and lock
|
||||
// the legitimate user out.
|
||||
router.post('/totp/setup', requireAuth, async (req: Request, res: Response) => {
|
||||
const { password } = req.body as { password?: string };
|
||||
if (!password) return res.status(400).json({ error: 'Current password is required to set up 2FA' });
|
||||
|
||||
const account = await queryOne<{ password_hash: string | null }>(
|
||||
`SELECT password_hash FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
if (!account?.password_hash) {
|
||||
return res.status(400).json({ error: 'This account has no local password (SSO). Set a password before enabling 2FA.' });
|
||||
}
|
||||
if (!(await bcrypt.compare(password, account.password_hash))) {
|
||||
return res.status(401).json({ error: 'Incorrect password' });
|
||||
}
|
||||
|
||||
const secret = new OTPAuth.Secret({ size: 20 });
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'MikroTik Manager',
|
||||
|
||||
@@ -86,8 +86,15 @@ router.put('/config', requireAuth, requireAdmin, async (req: Request, res: Respo
|
||||
const body = req.body as Partial<OidcConfig> & { client_secret?: string };
|
||||
|
||||
const validRole = (r: unknown): r is AppRole => APP_ROLES.includes(r as AppRole);
|
||||
if (body.default_role !== undefined && !validRole(body.default_role)) {
|
||||
return res.status(400).json({ error: 'Invalid default_role' });
|
||||
if (body.default_role !== undefined) {
|
||||
if (!validRole(body.default_role)) {
|
||||
return res.status(400).json({ error: 'Invalid default_role' });
|
||||
}
|
||||
// Auto-provisioning strangers straight to admin is almost never intended —
|
||||
// admin must be granted via an explicit group mapping, not the fallback.
|
||||
if (body.default_role === 'admin') {
|
||||
return res.status(400).json({ error: 'default_role cannot be admin — grant admin via an explicit group → role mapping instead' });
|
||||
}
|
||||
}
|
||||
if (body.group_role_map !== undefined) {
|
||||
if (typeof body.group_role_map !== 'object' || body.group_role_map === null) {
|
||||
|
||||
Reference in New Issue
Block a user