Files
p2ns/includes/infrastructure/rate_limit.js
T
2025-12-17 20:05:50 -05:00

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