reorg
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user