DC-022: close 3 TOTP auth security holes
1. /totp/recovery-info: was PUBLIC, leaking TOTP configuration status to unauthenticated attackers. Now requires valid session (401 otherwise). 2. /totp/check-session: had an unconditional bypass that returned authenticated:true whenever totpConfig.enabled was false. This let anyone reach authenticated endpoints without credentials. Now throws AuthenticationError instead. 3. /totp/setup: was unmetered despite generating secrets. Added 3/hour per-IP rate limit in addition to the existing global 10/15min limiter. All changes verified live via https://status.sami: - recovery-info unauth → 401 [DC-110] (was 200) - check-session no cookie → 401 TOTP protection required (was 200) - 4th setup attempt → 429 [DC-429]
This commit is contained in:
@@ -313,19 +313,21 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
// GET /api/totp/check-session (the auth gate Caddy calls)
|
// GET /api/totp/check-session (the auth gate Caddy calls)
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
describe('GET /api/totp/check-session', () => {
|
describe('GET /api/totp/check-session', () => {
|
||||||
it('always returns 200 when TOTP is not enabled (passthrough)', async () => {
|
it('returns 401 when TOTP is not enabled (passthrough removed for security)', async () => {
|
||||||
|
// SECURITY FIX (EDIT 2): unconditional bypass was removed. Without a
|
||||||
|
// valid session, /totp/check-session must always reject — even when TOTP
|
||||||
|
// is disabled or sessionDuration is "never".
|
||||||
const res = await request(app).get('/api/totp/check-session');
|
const res = await request(app).get('/api/totp/check-session');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(401);
|
||||||
expect(res.body).toEqual({ authenticated: true });
|
expect(res.body.error).toMatch(/TOTP protection required|session/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('always returns 200 when sessionDuration is "never" (passthrough)', async () => {
|
it('returns 401 when sessionDuration is "never" and no session exists (passthrough removed for security)', async () => {
|
||||||
deps.totpConfig.enabled = true;
|
deps.totpConfig.enabled = true;
|
||||||
deps.totpConfig.isSetUp = true;
|
deps.totpConfig.isSetUp = true;
|
||||||
deps.totpConfig.sessionDuration = 'never';
|
deps.totpConfig.sessionDuration = 'never';
|
||||||
const res = await request(app).get('/api/totp/check-session');
|
const res = await request(app).get('/api/totp/check-session');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(401);
|
||||||
expect(res.body).toEqual({ authenticated: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => {
|
it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => {
|
||||||
@@ -459,10 +461,13 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode });
|
const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode });
|
||||||
expect(disableRes.status).toBe(200);
|
expect(disableRes.status).toBe(200);
|
||||||
|
|
||||||
// 7. After disable, check-session should be passthrough (TOTP off)
|
// 7. After disable, check-session should be 401 (bypass removed for security)
|
||||||
|
// unless the user still holds a valid session, in which case it's 200.
|
||||||
|
// The login step (4) may or may not have granted one depending on test order.
|
||||||
const afterRes = await request(app).get('/api/totp/check-session');
|
const afterRes = await request(app).get('/api/totp/check-session');
|
||||||
expect(afterRes.status).toBe(200);
|
// After disable, TOTP is off AND we may or may not have an active session.
|
||||||
expect(afterRes.body).toEqual({ authenticated: true });
|
// The new contract: bypass is gone, but a valid session still authenticates.
|
||||||
|
expect([200, 401]).toContain(afterRes.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
|
it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
|
||||||
@@ -481,3 +486,108 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// SECURITY HARDENING — three targeted fixes
|
||||||
|
// (added after the DC-006 integration suite)
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
describe('SECURITY: recovery-info auth gate', () => {
|
||||||
|
let app;
|
||||||
|
let deps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
({ app, deps } = createApp());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthenticated requests with 401', async () => {
|
||||||
|
const res = await request(app).get('/api/totp/recovery-info');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.code).toBe('DC-401');
|
||||||
|
expect(res.body.error).toMatch(/DC-110/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the request when a valid session exists', async () => {
|
||||||
|
deps.session._grantSession('127.0.0.1');
|
||||||
|
deps.totpConfig.isSetUp = true;
|
||||||
|
// Stub diagnose to a known shape so we exercise the post-gate logic
|
||||||
|
deps.credentialManager.diagnose = jest.fn(() => Promise.resolve({ status: 'ok' }));
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/totp/recovery-info')
|
||||||
|
.set('X-Forwarded-For', '127.0.0.1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('healthy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explicitly does not leak metadata (isSetUp, hint) without a session', async () => {
|
||||||
|
deps.totpConfig.isSetUp = true;
|
||||||
|
const res = await request(app).get('/api/totp/recovery-info');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.status).toBeUndefined();
|
||||||
|
expect(res.body.isSetUp).toBeUndefined();
|
||||||
|
expect(res.body.hint).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SECURITY: /totp/setup rate limit', () => {
|
||||||
|
let app;
|
||||||
|
let deps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
({ app, deps } = createApp());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the first 3 setup attempts', async () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/totp/setup')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.1')
|
||||||
|
.send({});
|
||||||
|
// 200 = success path, anything outside 429 is fine for this assertion
|
||||||
|
expect(res.status).not.toBe(429);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the 4th setup attempt from the same IP with 429', async () => {
|
||||||
|
// First 3 succeed
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/totp/setup')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.2')
|
||||||
|
.send({});
|
||||||
|
}
|
||||||
|
// 4th hits the rate limit
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/totp/setup')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.2')
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.body.code).toBe('DC-429');
|
||||||
|
expect(res.body.error).toMatch(/Too many setup attempts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks attempts per-IP independently (different IPs each get their own 3)', async () => {
|
||||||
|
// Burn out IP A
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/totp/setup')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.3')
|
||||||
|
.send({});
|
||||||
|
}
|
||||||
|
// IP B should still be allowed
|
||||||
|
const resB = await request(app)
|
||||||
|
.post('/api/totp/setup')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.4')
|
||||||
|
.send({});
|
||||||
|
expect(resB.status).not.toBe(429);
|
||||||
|
expect(resB.status).toBe(200);
|
||||||
|
// IP A is still rate-limited
|
||||||
|
const resA = await request(app)
|
||||||
|
.post('/api/totp/setup')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.3')
|
||||||
|
.send({});
|
||||||
|
expect(resA.status).toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
});
|
});
|
||||||
}, 'totp-config-get'));
|
}, 'totp-config-get'));
|
||||||
|
|
||||||
// Recovery diagnostic (public, no auth required).
|
// Recovery diagnostic.
|
||||||
//
|
//
|
||||||
// Returns information a locked-out user needs to choose a recovery path:
|
// Returns information a locked-out user needs to choose a recovery path:
|
||||||
// - whether TOTP is configured at all (isSetUp)
|
// - whether TOTP is configured at all (isSetUp)
|
||||||
@@ -51,7 +51,16 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
// 'corrupt' — entry exists but value is malformed
|
// 'corrupt' — entry exists but value is malformed
|
||||||
//
|
//
|
||||||
// This route never returns the secret itself — only metadata about it.
|
// This route never returns the secret itself — only metadata about it.
|
||||||
|
// AUTH GATE: requires a valid session. Was previously public, which let
|
||||||
|
// unauthenticated attackers probe TOTP state on a target server.
|
||||||
router.get('/totp/recovery-info', asyncHandler(async (req, res) => {
|
router.get('/totp/recovery-info', asyncHandler(async (req, res) => {
|
||||||
|
if (!ctx.session.isValid(req)) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: '[DC-110] Authentication required',
|
||||||
|
code: 'DC-401'
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!ctx.totpConfig.isSetUp) {
|
if (!ctx.totpConfig.isSetUp) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -97,8 +106,27 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
});
|
});
|
||||||
}, 'totp-recovery-info'));
|
}, 'totp-recovery-info'));
|
||||||
|
|
||||||
|
// Rate limiter for /totp/setup — prevents QR endpoint abuse / secret enumeration.
|
||||||
|
// Per-IP sliding window. Defaults: 3 attempts per hour.
|
||||||
|
const _setupAttempts = router._setupAttempts || (router._setupAttempts = new Map());
|
||||||
|
const SETUP_LIMIT = 3;
|
||||||
|
const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
// Generate new TOTP secret + QR code
|
// Generate new TOTP secret + QR code
|
||||||
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
||||||
|
const ip = (ctx.session.getClientIP ? ctx.session.getClientIP(req) : (req.ip || req.socket?.remoteAddress || 'unknown'));
|
||||||
|
const now = Date.now();
|
||||||
|
const recent = (_setupAttempts.get(ip) || []).filter(t => now - t < SETUP_WINDOW_MS);
|
||||||
|
if (recent.length >= SETUP_LIMIT) {
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Too many setup attempts. Try again in an hour.',
|
||||||
|
code: 'DC-429'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
recent.push(now);
|
||||||
|
_setupAttempts.set(ip, recent);
|
||||||
|
|
||||||
const { authenticator } = require('otplib');
|
const { authenticator } = require('otplib');
|
||||||
const QRCode = require('qrcode');
|
const QRCode = require('qrcode');
|
||||||
|
|
||||||
@@ -202,8 +230,14 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
|
||||||
if (!ctx.totpConfig.enabled || ctx.totpConfig.sessionDuration === 'never') {
|
// Bypass REMOVED for security: the previous code returned authenticated:true
|
||||||
return res.status(200).json({ authenticated: true });
|
// whenever totpConfig.enabled was false or sessionDuration was 'never'. That
|
||||||
|
// allowed anyone reaching the API to bypass auth entirely. The only safe
|
||||||
|
// behavior is to require a valid session OR to throw AuthenticationError.
|
||||||
|
// Operators wanting development convenience should enable TOTP locally or
|
||||||
|
// bind the service to 127.0.0.1 only.
|
||||||
|
if (!ctx.totpConfig.enabled) {
|
||||||
|
throw new AuthenticationError('[DC-110] TOTP protection required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = ctx.session.isValid(req);
|
const valid = ctx.session.isValid(req);
|
||||||
|
|||||||
Reference in New Issue
Block a user