193 lines
5.5 KiB
JavaScript
193 lines
5.5 KiB
JavaScript
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 };
|
|
|