206 lines
5.9 KiB
JavaScript
206 lines
5.9 KiB
JavaScript
// middleware/rateLimiter.js
|
|
/**
|
|
* Rate Limiting Middleware
|
|
*
|
|
* Rate limits requests per userId (preferred) or IP (fallback)
|
|
* Returns 429 Too Many Requests when limit exceeded
|
|
*
|
|
* Uses Redis if available, falls back to in-memory store
|
|
*/
|
|
|
|
import { createClient } from 'redis';
|
|
|
|
// In-memory fallback store
|
|
const memoryStore = new Map();
|
|
|
|
// Clean up expired entries periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, value] of memoryStore.entries()) {
|
|
if (value.expiresAt < now) {
|
|
memoryStore.delete(key);
|
|
}
|
|
}
|
|
}, 60000); // Clean up every minute
|
|
|
|
// Redis client (lazy initialization)
|
|
let redisClient = null;
|
|
|
|
async function getRedisClient() {
|
|
if (redisClient) return redisClient;
|
|
|
|
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
|
|
try {
|
|
redisClient = createClient({ url: REDIS_URL });
|
|
redisClient.on('error', (err) => {
|
|
console.error('Redis Client Error:', err);
|
|
redisClient = null;
|
|
});
|
|
await redisClient.connect();
|
|
console.log('Redis connected for rate limiting');
|
|
return redisClient;
|
|
} catch (err) {
|
|
console.error('Failed to connect to Redis, using in-memory store:', err.message);
|
|
redisClient = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Rate limit configuration
|
|
const RATE_LIMIT_CONFIG = {
|
|
// Default limits
|
|
DEFAULT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
|
DEFAULT_WINDOW_SECONDS: parseInt(process.env.RATE_LIMIT_WINDOW_SECONDS || '900', 10), // 15 minutes
|
|
|
|
// Per-route custom limits can be specified when creating middleware
|
|
};
|
|
|
|
/**
|
|
* Increment counter in Redis or memory store
|
|
*/
|
|
async function incrementCounter(key, ttlSeconds) {
|
|
const redis = await getRedisClient();
|
|
|
|
if (redis) {
|
|
try {
|
|
const count = await redis.incr(key);
|
|
if (count === 1) {
|
|
// First increment, set TTL
|
|
await redis.expire(key, ttlSeconds);
|
|
}
|
|
return count;
|
|
} catch (err) {
|
|
console.error('Redis increment error, falling back to memory:', err.message);
|
|
// Fall through to memory store
|
|
}
|
|
}
|
|
|
|
// Memory store fallback
|
|
const now = Date.now();
|
|
const stored = memoryStore.get(key);
|
|
|
|
if (stored && stored.expiresAt > now) {
|
|
stored.count++;
|
|
return stored.count;
|
|
} else {
|
|
memoryStore.set(key, {
|
|
count: 1,
|
|
expiresAt: now + (ttlSeconds * 1000),
|
|
});
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get counter value
|
|
*/
|
|
async function getCounter(key) {
|
|
const redis = await getRedisClient();
|
|
|
|
if (redis) {
|
|
try {
|
|
const count = await redis.get(key);
|
|
return count ? parseInt(count, 10) : 0;
|
|
} catch (err) {
|
|
// Fall through to memory store
|
|
}
|
|
}
|
|
|
|
// Memory store fallback
|
|
const stored = memoryStore.get(key);
|
|
const now = Date.now();
|
|
if (stored && stored.expiresAt > now) {
|
|
return stored.count || 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Rate Limiting Middleware Factory
|
|
*
|
|
* @param {Object} options - Rate limit options
|
|
* @param {number} options.maxRequests - Maximum requests per window (default: 100)
|
|
* @param {number} options.windowSeconds - Time window in seconds (default: 900 = 15 minutes)
|
|
* @param {string} options.type - Rate limit type for logging (default: 'default')
|
|
* @returns {Function} Express middleware
|
|
*/
|
|
function createRateLimiter(options = {}) {
|
|
const maxRequests = options.maxRequests || RATE_LIMIT_CONFIG.DEFAULT_MAX_REQUESTS;
|
|
const windowSeconds = options.windowSeconds || RATE_LIMIT_CONFIG.DEFAULT_WINDOW_SECONDS;
|
|
const type = options.type || 'default';
|
|
|
|
return async function rateLimiter(req, res, next) {
|
|
try {
|
|
// Determine rate limit key (prefer userId, fallback to IP)
|
|
const userId = req.user?.userId;
|
|
const clientIp = req.clientIp || 'unknown';
|
|
|
|
const key = userId
|
|
? `rate_limit:${type}:user:${userId}`
|
|
: `rate_limit:${type}:ip:${clientIp}`;
|
|
|
|
// Increment counter
|
|
const count = await incrementCounter(key, windowSeconds);
|
|
|
|
// Check if limit exceeded
|
|
if (count > maxRequests) {
|
|
// Log rate limit exceeded
|
|
if (req.auditLogger) {
|
|
req.auditLogger.log({
|
|
userId,
|
|
action: 'rate_limit_exceeded',
|
|
route: req.path,
|
|
status: 'blocked',
|
|
meta: { count, maxRequests, windowSeconds, type, key },
|
|
});
|
|
}
|
|
|
|
return res.status(429).json({
|
|
error: 'Too Many Requests',
|
|
message: `Rate limit exceeded. Maximum ${maxRequests} requests per ${windowSeconds} seconds allowed.`,
|
|
retry_after: windowSeconds,
|
|
limit_type: type,
|
|
});
|
|
}
|
|
|
|
// Add rate limit headers
|
|
const remaining = Math.max(0, maxRequests - count);
|
|
const resetTime = new Date(Date.now() + (windowSeconds * 1000));
|
|
|
|
res.setHeader('X-RateLimit-Limit', maxRequests);
|
|
res.setHeader('X-RateLimit-Remaining', remaining);
|
|
res.setHeader('X-RateLimit-Reset', resetTime.toISOString());
|
|
res.setHeader('X-RateLimit-Type', type);
|
|
|
|
next();
|
|
} catch (err) {
|
|
console.error('Rate limiter error:', err);
|
|
// On error, allow the request to proceed (fail open)
|
|
// This ensures legitimate users aren't blocked if rate limiting fails
|
|
next();
|
|
}
|
|
};
|
|
}
|
|
|
|
// Export pre-configured rate limiters
|
|
export const rateLimiterRead = createRateLimiter({
|
|
maxRequests: parseInt(process.env.RATE_LIMIT_READ_MAX || '100', 10),
|
|
windowSeconds: parseInt(process.env.RATE_LIMIT_READ_WINDOW || '900', 10),
|
|
type: 'read',
|
|
});
|
|
|
|
export const rateLimiterWrite = createRateLimiter({
|
|
maxRequests: parseInt(process.env.RATE_LIMIT_WRITE_MAX || '20', 10),
|
|
windowSeconds: parseInt(process.env.RATE_LIMIT_WRITE_WINDOW || '900', 10),
|
|
type: 'write',
|
|
});
|
|
|
|
export const rateLimiterSensitive = createRateLimiter({
|
|
maxRequests: parseInt(process.env.RATE_LIMIT_SENSITIVE_MAX || '10', 10),
|
|
windowSeconds: parseInt(process.env.RATE_LIMIT_SENSITIVE_WINDOW || '3600', 10),
|
|
type: 'sensitive',
|
|
});
|
|
|
|
export default createRateLimiter;
|