From 39f1c8825558df23ca4795429c91284ed3dc7ded Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 5 Sep 2026 22:48:48 -0400 Subject: [PATCH] fix(ratelimit): split global and login budgets so storms can't lock out login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared 100 req / 10 min limiter counted everything — 401s, CORS preflights, even /health — so a bad-token burst from a second frontend burned the budget and 429'd POST /api/auth/login too, leaving no way back in short of waiting out the window or restarting (MemoryStore reset). New middleware/rateLimit.js holds two limiters, verified against express-rate-limit@5.5.1: - apiLimiter (300 / 15 min) skips OPTIONS, /health, and the public auth endpoints, so preflights, probes, and login attempts never drain real API budget. - authLimiter (20 / 15 min, successful logins free) guards register/login/forgotpassword/resetpassword against brute force. 429 there means 20 wrong passwords, never a busy API. Storm-tested with live server: 320 bad-token hits burn the global budget yet login still returns 200; 25 bad logins trip only the login limiter while API traffic is untouched. --- app.js | 9 ++------- middleware/rateLimit.js | 37 +++++++++++++++++++++++++++++++++++++ routes/auth.js | 9 +++++---- 3 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 middleware/rateLimit.js diff --git a/app.js b/app.js index 50dc932..b0f827a 100644 --- a/app.js +++ b/app.js @@ -6,10 +6,10 @@ import helmet from 'helmet' import cookieParser from 'cookie-parser' import mongoSanitize from 'express-mongo-sanitize' import xss from 'xss-clean' -import rateLimit from 'express-rate-limit' import hpp from 'hpp' import morgan from 'morgan' import errorHandler from './middleware/error.js' +import { apiLimiter } from './middleware/rateLimit.js' @@ -36,12 +36,7 @@ const corsOptions = { }, } -const limiter = rateLimit({ - windowMs: 10 * 60 * 1000, // 10 minutes - max: 100 -}) - -app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), limiter, hpp(), cors()) +app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), apiLimiter, hpp(), cors()) app.get('/health', (req, res) => res.status(200).json({ status: 'ok' })) diff --git a/middleware/rateLimit.js b/middleware/rateLimit.js new file mode 100644 index 0000000..62561cf --- /dev/null +++ b/middleware/rateLimit.js @@ -0,0 +1,37 @@ +import rateLimit from 'express-rate-limit' + +// Public auth endpoints get their own brute-force budget (see authLimiter) +// and must not consume the shared API budget, otherwise a bad-token storm +// can lock the owner out of logging back in. +const PUBLIC_AUTH_PATHS = [ + '/api/auth/login', + '/api/auth/register', + '/api/auth/forgotpassword', + '/api/auth/resetpassword', +] + +// Shared budget for real API traffic. Counts failures too (cheap 401s, no DB +// hit), but skips CORS preflights, the health probe, and the public auth +// endpoints above so junk traffic and login attempts can't drain it. +export const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 300, + skip: (req) => + req.method === 'OPTIONS' || + req.path === '/health' || + PUBLIC_AUTH_PATHS.some((p) => req.path.startsWith(p)), +}) + +// Strict budget for the login door only. Successful logins are free +// (skipSuccessfulRequests), so normal use never notices it — only repeated +// failed attempts burn budget. 429 here means "wrong password 20 times in +// 15 minutes", never "the API was busy". +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, + skipSuccessfulRequests: true, + message: { + success: false, + error: 'Too many login attempts, please try again later.', + }, +}) diff --git a/routes/auth.js b/routes/auth.js index a2e2866..366b9bd 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -4,14 +4,15 @@ import express from 'express' const router = express.Router() import { register, login, getMe, forgotPassword, resetPassword, updateDetails, updatePassword, logout } from '../controllers/auth.js' import { protect } from '../middleware/auth.js' +import { authLimiter } from '../middleware/rateLimit.js' -router.post('/register', register) -router.post('/login', login) +router.post('/register', authLimiter, register) +router.post('/login', authLimiter, login) router.get('/logout', logout) router.get('/me', protect, getMe) router.put('/updatedetails', protect, updateDetails) -router.post('/forgotpassword', forgotPassword) -router.put('/resetpassword/:resettoken', resetPassword) +router.post('/forgotpassword', authLimiter, forgotPassword) +router.put('/resetpassword/:resettoken', authLimiter, resetPassword) router.put('/updatepassword', protect, updatePassword) export default router