diff --git a/index.js b/index.js index a4ba20e..44d5945 100644 --- a/index.js +++ b/index.js @@ -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'); diff --git a/middleware/authRateLimiter.js b/middleware/authRateLimiter.js new file mode 100644 index 0000000..f8f86b4 --- /dev/null +++ b/middleware/authRateLimiter.js @@ -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, +};