/** * In-memory rate limiter * Stores counters in a Map — resets on server restart, no DB required. */ // Persist the store on globalThis so that module-cache invalidation (e.g. during // Next.js hot reload) does not silently reset all counters within the same process. // CRITICAL LIMITATION: this Map is process-local. In serverless or multi-worker // deployments every instance maintains its own store and rate limits do not // distribute across instances. For production deployments with multiple workers // replace this Map with a shared atomic store (e.g. Redis / Upstash). const STORE_KEY = Symbol.for('__ZEN_RATE_LIMIT_STORE__'); if (!globalThis[STORE_KEY]) globalThis[STORE_KEY] = new Map(); /** @type {Map} */ const store = globalThis[STORE_KEY]; // Purge expired entries every 10 minutes to avoid memory leak const cleanup = setInterval(() => { const now = Date.now(); for (const [key, entry] of store.entries()) { const windowExpired = now > entry.windowStart + entry.windowMs; const blockExpired = !entry.blockedUntil || now > entry.blockedUntil; if (windowExpired && blockExpired) { store.delete(key); } } }, 10 * 60 * 1000); // Allow garbage collection in test/serverless environments if (cleanup.unref) cleanup.unref(); /** * Rate limit presets per action. * maxAttempts : number of requests allowed in the window * windowMs : rolling window duration * blockMs : how long to block once the limit is exceeded */ export const RATE_LIMITS = { login: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 }, register: { maxAttempts: 5, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 }, forgot_password: { maxAttempts: 3, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 }, reset_password: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 }, verify_email: { maxAttempts: 10, windowMs: 60 * 60 * 1000, blockMs: 30 * 60 * 1000 }, api: { maxAttempts: 120, windowMs: 60 * 1000, blockMs: 5 * 60 * 1000 }, }; /** * Check whether a given identifier is allowed for an action, and record the attempt. * * @param {string} identifier - IP address or user ID * @param {string} action - Key from RATE_LIMITS (e.g. 'login') * @returns {{ allowed: boolean, retryAfterMs?: number, remaining?: number }} */ export function checkRateLimit(identifier, action) { const config = RATE_LIMITS[action]; if (!config) return { allowed: true }; const key = `${action}:${identifier}`; const now = Date.now(); let entry = store.get(key); // Still blocked if (entry?.blockedUntil && now < entry.blockedUntil) { return { allowed: false, retryAfterMs: entry.blockedUntil - now }; } // Start a fresh window (first request, or previous window has expired) if (!entry || now > entry.windowStart + entry.windowMs) { store.set(key, { count: 1, windowStart: now, windowMs: config.windowMs, blockedUntil: null }); return { allowed: true, remaining: config.maxAttempts - 1 }; } // Increment counter in the current window entry.count += 1; if (entry.count > config.maxAttempts) { entry.blockedUntil = now + config.blockMs; store.set(key, entry); return { allowed: false, retryAfterMs: config.blockMs }; } store.set(key, entry); return { allowed: true, remaining: config.maxAttempts - entry.count }; } /** * Return true only when the string resembles a valid IPv4 or IPv6 address. * This prevents arbitrary attacker-supplied strings from being used as * rate-limit identifiers (which could allow bucket manipulation). * @param {string|null|undefined} ip * @returns {boolean} */ function isValidIp(ip) { if (!ip || typeof ip !== 'string') return false; // IPv4 — four decimal octets, each 0-255 if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) { return ip.split('.').every(octet => parseInt(octet, 10) <= 255); } // IPv6 — simplified structural check (colons + hex groups) return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(':'); } /** * Extract the client IP from Next.js headers() (server actions). * * X-Forwarded-For and X-Real-IP are only trusted when ZEN_TRUST_PROXY=true is * explicitly set, confirming a trusted reverse proxy populates those headers. * Without this flag the headers are fully attacker-controlled and MUST NOT be * used as rate-limit keys — an attacker would trivially rotate identifiers. * * Set ZEN_TRUST_PROXY=true only when a verified reverse proxy (e.g. Nginx, * Cloudflare, AWS ALB) strips and rewrites forwarded headers before they reach * this application. * * @param {import('next/headers').ReadonlyHeaders} headersList * @returns {string} */ export function getIpFromHeaders(headersList) { if (process.env.ZEN_TRUST_PROXY === 'true') { const forwarded = headersList.get('x-forwarded-for')?.split(',')[0]?.trim(); if (forwarded && isValidIp(forwarded)) return forwarded; const realIp = headersList.get('x-real-ip')?.trim(); if (realIp && isValidIp(realIp)) return realIp; } // In development, use loopback so rate limiting stays active and the // "IP cannot be determined" warning is not emitted. // In production without a trusted proxy, return 'unknown' to suspend rate // limiting rather than collapse all traffic into one shared bucket. // Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy. return process.env.NODE_ENV === 'development' ? '127.0.0.1' : 'unknown'; } /** * Extract the client IP from a Next.js Request object (API routes). * See getIpFromHeaders for the full trust-proxy rationale. * @param {Request} request * @returns {string} */ export function getIpFromRequest(request) { if (process.env.ZEN_TRUST_PROXY === 'true') { const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); if (forwarded && isValidIp(forwarded)) return forwarded; const realIp = request.headers.get('x-real-ip')?.trim(); if (realIp && isValidIp(realIp)) return realIp; } return process.env.NODE_ENV === 'development' ? '127.0.0.1' : 'unknown'; } /** * Format a block duration in human-readable French. * @param {number} ms * @returns {string} */ export function formatRetryAfter(ms) { const seconds = Math.ceil(ms / 1000); if (seconds < 60) return `${seconds} secondes`; const minutes = Math.ceil(seconds / 60); if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''}`; const hours = Math.ceil(minutes / 60); return `${hours} heure${hours > 1 ? 's' : ''}`; }