// 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;