Files
codey.lol/src/utils/rateLimit.js
2025-12-02 10:05:43 -05:00

233 lines
6.6 KiB
JavaScript

/**
* Robust rate limiter using both IP address and cookie-based tracking.
* Implements sliding window with burst protection and automatic cleanup.
*/
// Separate maps for IP and cookie tracking
const ipRateLimitMap = new Map();
const cookieRateLimitMap = new Map();
// Global flood protection - track overall request volume per IP
const floodProtectionMap = new Map();
// Cleanup old entries every 60 seconds to prevent memory leaks
const CLEANUP_INTERVAL = 60_000;
let lastCleanup = Date.now();
function cleanupStaleEntries() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [key, entry] of ipRateLimitMap) {
if (now > entry.resetTime + 60_000) ipRateLimitMap.delete(key);
}
for (const [key, entry] of cookieRateLimitMap) {
if (now > entry.resetTime + 60_000) cookieRateLimitMap.delete(key);
}
for (const [key, entry] of floodProtectionMap) {
if (now > entry.resetTime + 60_000) floodProtectionMap.delete(key);
}
}
/**
* Extract client IP from request headers with proxy support.
* Falls back through common proxy headers.
*/
export function getClientIp(request) {
const headers = request.headers;
// Try standard proxy headers in order of preference
const xForwardedFor = headers.get('x-forwarded-for');
if (xForwardedFor) {
// Take first IP (original client), trim whitespace
const ip = xForwardedFor.split(',')[0].trim();
if (ip && isValidIp(ip)) return normalizeIp(ip);
}
const xRealIp = headers.get('x-real-ip');
if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp);
const cfConnectingIp = headers.get('cf-connecting-ip');
if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp);
const trueClientIp = headers.get('true-client-ip');
if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp);
// Fallback
return 'unknown';
}
/**
* Basic IP validation to prevent header injection attacks.
*/
function isValidIp(ip) {
if (!ip || typeof ip !== 'string') return false;
// Basic sanity check - no weird characters, reasonable length
if (ip.length > 45) return false; // Max IPv6 length
if (!/^[\d.:a-fA-F]+$/.test(ip)) return false;
return true;
}
/**
* Normalize IP for consistent keying (lowercase, trim).
*/
function normalizeIp(ip) {
return ip.toLowerCase().trim();
}
/**
* Extract nonce cookie from request.
*/
export function getCookieId(request) {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) return null;
const match = cookieHeader.split(';').find(c => c.trim().startsWith('nonce='));
if (!match) return null;
const value = match.split('=')[1]?.trim();
// Validate UUID format loosely
if (!value || value.length < 32 || value.length > 40) return null;
return value;
}
/**
* Check if an identifier is rate limited.
*/
function isLimited(map, key, limit, windowMs) {
const now = Date.now();
const entry = map.get(key);
if (!entry || now > entry.resetTime) {
return false; // Not limited, window expired or new
}
return entry.count >= limit;
}
/**
* Record a request for rate limiting.
*/
function recordHit(map, key, windowMs) {
const now = Date.now();
const entry = map.get(key);
if (!entry || now > entry.resetTime) {
map.set(key, { count: 1, resetTime: now + windowMs });
} else {
entry.count++;
}
}
/**
* Get current count for an identifier.
*/
function getCount(map, key) {
const now = Date.now();
const entry = map.get(key);
if (!entry || now > entry.resetTime) return 0;
return entry.count;
}
/**
* Check flood protection - aggressive rate limit for potential abuse.
* This triggers when an IP sends too many requests in a short burst.
*/
function checkFloodProtection(ip, burstLimit = 30, burstWindowMs = 10_000) {
if (ip === 'unknown') return false; // Can't flood-protect unknown IPs effectively
const now = Date.now();
const entry = floodProtectionMap.get(ip);
if (!entry || now > entry.resetTime) {
floodProtectionMap.set(ip, { count: 1, resetTime: now + burstWindowMs });
return false;
}
entry.count++;
return entry.count > burstLimit;
}
/**
* Main rate limit check combining IP and cookie tracking.
*
* @param {Request} request - The incoming request
* @param {Object} options - Rate limit configuration
* @param {number} options.limit - Max requests per window
* @param {number} options.windowMs - Window duration in ms
* @param {number} options.burstLimit - Flood protection burst limit
* @param {number} options.burstWindowMs - Flood protection window
* @returns {{ allowed: boolean, ip: string, cookieId: string|null, isFlooding: boolean }}
*/
export function checkRateLimit(request, options = {}) {
const {
limit = 5,
windowMs = 1000,
burstLimit = 30,
burstWindowMs = 10_000,
} = options;
cleanupStaleEntries();
const ip = getClientIp(request);
const cookieId = getCookieId(request);
// Check flood protection first (aggressive anti-abuse)
const isFlooding = checkFloodProtection(ip, burstLimit, burstWindowMs);
if (isFlooding) {
return { allowed: false, ip, cookieId, isFlooding: true };
}
// Check both IP and cookie limits
// Both must be under limit to allow the request
const ipLimited = ip !== 'unknown' && isLimited(ipRateLimitMap, ip, limit, windowMs);
const cookieLimited = cookieId && isLimited(cookieRateLimitMap, cookieId, limit, windowMs);
// If either is limited, deny
if (ipLimited || cookieLimited) {
return { allowed: false, ip, cookieId, isFlooding: false };
}
return { allowed: true, ip, cookieId, isFlooding: false };
}
/**
* Record a successful request for rate limiting tracking.
* Call this after the request is processed.
*/
export function recordRequest(request, windowMs = 1000) {
const ip = getClientIp(request);
const cookieId = getCookieId(request);
if (ip !== 'unknown') {
recordHit(ipRateLimitMap, ip, windowMs);
}
if (cookieId) {
recordHit(cookieRateLimitMap, cookieId, windowMs);
}
}
/**
* Generate a new nonce cookie value.
*/
export function generateNonce() {
return crypto.randomUUID();
}
/**
* Create Set-Cookie header value for nonce.
*/
export function createNonceCookie(nonce) {
return `nonce=${nonce}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=31536000`;
}
export default {
checkRateLimit,
recordRequest,
getClientIp,
getCookieId,
generateNonce,
createNonceCookie,
};