fix(auth): add account+IP brute-force rate limiting
This commit is contained in:
13
index.js
13
index.js
@@ -9,6 +9,7 @@ require('dotenv').config();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
app.set('trust proxy', true);
|
||||||
// -- Accept request from other origins
|
// -- Accept request from other origins
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const { corsOptions } = require('./config/corsOptions');
|
const { corsOptions } = require('./config/corsOptions');
|
||||||
@@ -34,15 +35,11 @@ const limiter = rateLimit({
|
|||||||
return ip.includes(":") ? ip.split(":")[0] : ip; // Remove port if present
|
return ip.includes(":") ? ip.split(":")[0] : ip; // Remove port if present
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.set('trust proxy', true);
|
|
||||||
app.use(limiter);
|
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
|
// Authentication
|
||||||
const { signup, login, logout, resetPassword, loginWithPasswordToken } = require('./auth/authEmail.js');
|
const { signup, login, logout, resetPassword, loginWithPasswordToken } = require('./auth/authEmail.js');
|
||||||
|
const { authRateLimiter } = require('./middleware/authRateLimiter');
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /signup:
|
* /signup:
|
||||||
@@ -110,7 +107,7 @@ app.post('/signup', signup);
|
|||||||
* description: Invalid credentials.
|
* description: Invalid credentials.
|
||||||
*/
|
*/
|
||||||
// SECURITY FIX (#2): POST-only login to avoid query-string credential leakage.
|
// SECURITY FIX (#2): POST-only login to avoid query-string credential leakage.
|
||||||
app.post('/login', login);
|
app.post('/login', authRateLimiter('login'), login);
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /logout:
|
* /logout:
|
||||||
@@ -158,7 +155,7 @@ app.get('/logout', logout);
|
|||||||
* 400:
|
* 400:
|
||||||
* description: Bad request.
|
* description: Bad request.
|
||||||
*/
|
*/
|
||||||
app.route('/resetPassword').post(resetPassword);
|
app.route('/resetPassword').post(authRateLimiter('reset'), resetPassword);
|
||||||
// SECURITY FIX (#1):
|
// SECURITY FIX (#1):
|
||||||
// Single-use token login endpoint for password recovery flow.
|
// Single-use token login endpoint for password recovery flow.
|
||||||
/**
|
/**
|
||||||
@@ -182,7 +179,7 @@ app.route('/resetPassword').post(resetPassword);
|
|||||||
* 401:
|
* 401:
|
||||||
* description: Invalid or expired token
|
* description: Invalid or expired token
|
||||||
*/
|
*/
|
||||||
app.post('/password/token-login', loginWithPasswordToken);
|
app.post('/password/token-login', authRateLimiter('token'), loginWithPasswordToken);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
const profileRoute = require('./routes/profile.js');
|
const profileRoute = require('./routes/profile.js');
|
||||||
|
|||||||
116
middleware/authRateLimiter.js
Normal file
116
middleware/authRateLimiter.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user