const { logWarn } = require('./logger'); const { parseSecondsToMs, secondsToMs } = require('./utils'); // Default window size: 1 minute = 60 seconds const DEFAULT_WINDOW_MS = secondsToMs(60); // Simple in-memory rate limiter class RateLimiter { constructor(maxRequests = 100, windowMs = DEFAULT_WINDOW_MS) { this.maxRequests = maxRequests; this.windowMs = windowMs; this.requests = new Map(); // IP -> { count, resetTime } this.cleanupInterval = setInterval(() => this.cleanup(), windowMs); } cleanup() { const now = Date.now(); for (const [ip, data] of this.requests.entries()) { if (now > data.resetTime) { this.requests.delete(ip); } } } check(ip) { const now = Date.now(); const data = this.requests.get(ip); if (!data || now > data.resetTime) { // New window this.requests.set(ip, { count: 1, resetTime: now + this.windowMs }); return { allowed: true, remaining: this.maxRequests - 1 }; } if (data.count >= this.maxRequests) { logWarn('RateLimit', `Rate limit exceeded for IP: ${ip}`); return { allowed: false, remaining: 0, resetTime: data.resetTime }; } data.count++; return { allowed: true, remaining: this.maxRequests - data.count }; } destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } this.requests.clear(); } } // Global rate limiter instance const rateLimiter = new RateLimiter( parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10), parseSecondsToMs(process.env.RATE_LIMIT_WINDOW_MS || '60') // 1 minute = 60 seconds ); /** * Check if an IP address is a local/loopback address * @param {string} ip - IP address to check * @returns {boolean} - True if local IP */ function isLocalIP(ip) { if (!ip || ip === 'unknown') return false; // IPv4 loopback if (ip === '127.0.0.1' || ip === 'localhost') return true; // IPv6 loopback if (ip === '::1' || ip === '::ffff:127.0.0.1') return true; // Private network ranges if (ip.startsWith('192.168.')) return true; if (ip.startsWith('10.')) return true; if (ip.startsWith('172.16.') || ip.startsWith('172.17.') || ip.startsWith('172.18.') || ip.startsWith('172.19.') || ip.startsWith('172.20.') || ip.startsWith('172.21.') || ip.startsWith('172.22.') || ip.startsWith('172.23.') || ip.startsWith('172.24.') || ip.startsWith('172.25.') || ip.startsWith('172.26.') || ip.startsWith('172.27.') || ip.startsWith('172.28.') || ip.startsWith('172.29.') || ip.startsWith('172.30.') || ip.startsWith('172.31.')) return true; // IPv6 private ranges if (ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:')) return true; return false; } /** * Check if an endpoint should be exempt from rate limiting * @param {string} path - Request path * @param {string} method - HTTP method * @returns {boolean} - True if exempt */ function isExemptEndpoint(path, method) { // GET requests are generally safe and expected to be frequent if (method === 'GET') return true; // Stats endpoints are expected to be polled frequently if (path === '/api/stats' || path.startsWith('/api/stats/')) return true; // Health check endpoints if (path === '/api/health' || path === '/api/status') return true; return false; } /** * Middleware function to check rate limits * @param {object} req - Request object * @returns {object|null} - Error response or null if allowed */ function checkRateLimit(req) { const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'; // Bypass rate limiting for local IPs if (isLocalIP(ip)) { return null; } // Check if endpoint is exempt const urlPath = req.url?.split('?')[0] || ''; const method = req.method || 'GET'; if (isExemptEndpoint(urlPath, method)) { return null; } const result = rateLimiter.check(ip); if (!result.allowed) { return { statusCode: 429, headers: { 'Content-Type': 'application/json', 'X-RateLimit-Limit': rateLimiter.maxRequests.toString(), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': new Date(result.resetTime).toISOString(), 'Retry-After': Math.ceil((result.resetTime - Date.now()) / 1000).toString() }, body: JSON.stringify({ error: 'Too Many Requests', message: 'Rate limit exceeded. Please try again later.' }) }; } return null; } /** * Update rate limiter configuration at runtime * @param {number} maxRequests - New max requests per window * @param {number} windowMs - New window size in milliseconds */ function updateRateLimiter(maxRequests, windowMs) { // Clear old cleanup interval if (rateLimiter.cleanupInterval) { clearInterval(rateLimiter.cleanupInterval); } // Update properties rateLimiter.maxRequests = maxRequests; rateLimiter.windowMs = windowMs; // Update existing request reset times to use new window const now = Date.now(); for (const [ip, data] of rateLimiter.requests.entries()) { // Adjust reset time if it's beyond the new window if (data.resetTime > now + windowMs) { data.resetTime = now + windowMs; } } // Create new cleanup interval with new window rateLimiter.cleanupInterval = setInterval(() => rateLimiter.cleanup(), windowMs); } module.exports = { checkRateLimit, rateLimiter, updateRateLimiter };