fix(auth): add account+IP brute-force rate limiting

This commit is contained in:
Adolfo Reyna
2026-02-20 21:20:21 -05:00
parent 469962d03c
commit 19d805d322
2 changed files with 121 additions and 8 deletions

View File

@@ -9,6 +9,7 @@ require('dotenv').config();
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.set('trust proxy', true);
// -- Accept request from other origins
const cors = require('cors');
const { corsOptions } = require('./config/corsOptions');
@@ -34,15 +35,11 @@ const limiter = rateLimit({
return ip.includes(":") ? ip.split(":")[0] : ip; // Remove port if present
}
});
app.set('trust proxy', true);
app.use(limiter);
// SECURITY PLAN (point #3):
// Add dedicated auth limiter(s) for /login and password reset endpoints.
// Use tighter thresholds than the global limiter and key by account+IP.
// Authentication
const { signup, login, logout, resetPassword, loginWithPasswordToken } = require('./auth/authEmail.js');
const { authRateLimiter } = require('./middleware/authRateLimiter');
/**
* @swagger
* /signup:
@@ -110,7 +107,7 @@ app.post('/signup', signup);
* description: Invalid credentials.
*/
// SECURITY FIX (#2): POST-only login to avoid query-string credential leakage.
app.post('/login', login);
app.post('/login', authRateLimiter('login'), login);
/**
* @swagger
* /logout:
@@ -158,7 +155,7 @@ app.get('/logout', logout);
* 400:
* description: Bad request.
*/
app.route('/resetPassword').post(resetPassword);
app.route('/resetPassword').post(authRateLimiter('reset'), resetPassword);
// SECURITY FIX (#1):
// Single-use token login endpoint for password recovery flow.
/**
@@ -182,7 +179,7 @@ app.route('/resetPassword').post(resetPassword);
* 401:
* description: Invalid or expired token
*/
app.post('/password/token-login', loginWithPasswordToken);
app.post('/password/token-login', authRateLimiter('token'), loginWithPasswordToken);
// Routes
const profileRoute = require('./routes/profile.js');

View File

@@ -0,0 +1,116 @@
const crypto = require('crypto');
const { client_logger } = require('../utils/analyticsLogger');
const AUTH_ATTEMPT_WINDOW_MS = Math.max(60 * 1000, parseInt(process.env.AUTH_ATTEMPT_WINDOW_MS || `${15 * 60 * 1000}`, 10));
const AUTH_ATTEMPT_MAX = Math.max(1, parseInt(process.env.AUTH_ATTEMPT_MAX || '5', 10));
const AUTH_BLOCK_BASE_MS = Math.max(30 * 1000, parseInt(process.env.AUTH_BLOCK_BASE_MS || `${5 * 60 * 1000}`, 10));
const AUTH_BLOCK_MAX_MS = Math.max(AUTH_BLOCK_BASE_MS, parseInt(process.env.AUTH_BLOCK_MAX_MS || `${60 * 60 * 1000}`, 10));
const limiterStore = new Map();
let lastPruneAt = 0;
const getClientIp = (req) => {
const forwarded = req.headers['x-forwarded-for']?.split(',')[0]?.trim();
const rawIp = forwarded || req.ip || req.connection?.remoteAddress || 'unknown';
return rawIp.replace('::ffff:', '');
};
const hashValue = (value) =>
crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16);
const getIdentity = (req, mode) => {
if (mode === 'token') {
const token = (req.body?.token || '').trim();
return token ? `token:${hashValue(token)}` : 'token:anonymous';
}
const username = (req.body?.username || req.body?.email || '').trim().toLowerCase();
return username ? `acct:${hashValue(username)}` : 'acct:anonymous';
};
const getLimiterKey = (req, mode) => `${mode}:${getIdentity(req, mode)}:ip:${getClientIp(req)}`;
const getOrInitRecord = (key, now) => {
const existing = limiterStore.get(key);
if (existing) {
return existing;
}
const record = {
count: 0,
windowStartedAt: now,
blockedUntil: 0,
blockLevel: 0,
};
limiterStore.set(key, record);
return record;
};
const computeBlockMs = (blockLevel) =>
Math.min(AUTH_BLOCK_BASE_MS * (2 ** Math.max(0, blockLevel - 1)), AUTH_BLOCK_MAX_MS);
const authRateLimiter = (mode) => (req, res, next) => {
const now = Date.now();
if (now - lastPruneAt > 5 * 60 * 1000) {
for (const [storeKey, storeValue] of limiterStore.entries()) {
const isWindowExpired = now - storeValue.windowStartedAt > AUTH_ATTEMPT_WINDOW_MS;
const isNotBlocked = storeValue.blockedUntil <= now;
if (isWindowExpired && isNotBlocked) {
limiterStore.delete(storeKey);
}
}
lastPruneAt = now;
}
const key = getLimiterKey(req, mode);
const record = getOrInitRecord(key, now);
if (now - record.windowStartedAt > AUTH_ATTEMPT_WINDOW_MS) {
record.count = 0;
record.windowStartedAt = now;
}
if (record.blockedUntil > now) {
const retryAfterSec = Math.ceil((record.blockedUntil - now) / 1000);
res.set('Retry-After', retryAfterSec.toString());
client_logger.capture({
distinctId: 'app_level',
event: 'security@auth@rate_limited',
properties: {
route: req.originalUrl,
method: req.method,
mode,
keyHash: hashValue(key),
retryAfterSec,
blockLevel: record.blockLevel,
}
});
return res.status(429).json({ status: 'Too many attempts. Please try again later.' });
}
record.count += 1;
if (record.count > AUTH_ATTEMPT_MAX) {
record.blockLevel += 1;
const blockMs = computeBlockMs(record.blockLevel);
record.blockedUntil = now + blockMs;
record.count = 0;
record.windowStartedAt = now;
res.set('Retry-After', Math.ceil(blockMs / 1000).toString());
client_logger.capture({
distinctId: 'app_level',
event: 'security@auth@rate_limited',
properties: {
route: req.originalUrl,
method: req.method,
mode,
keyHash: hashValue(key),
retryAfterSec: Math.ceil(blockMs / 1000),
blockLevel: record.blockLevel,
}
});
return res.status(429).json({ status: 'Too many attempts. Please try again later.' });
}
return next();
};
module.exports = {
authRateLimiter,
};