233 lines
6.6 KiB
JavaScript
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,
|
|
};
|