import rateLimit from 'express-rate-limit'; import RedisStore from 'rate-limit-redis'; import { createHash } from 'crypto'; import type { Request } from 'express'; import { redis } from '../../config/redis'; /** * Generate a rate-limit key combining both IP AND target email (2026-04-12). * * Pure IP rate limits can be bypassed by rotating IPs (easy on mobile/VPN), * and pure email rate limits can be DoS'd by an attacker hitting every known * email from many IPs to lock legitimate users out. Combining both means: * - a single IP can't hammer a single email beyond the limit * - a single IP still can't spray many different emails beyond a wider cap * The email is hashed to keep it out of Redis in plaintext. */ function keyForEmailAndIp(prefix: string) { return (req: Request): string => { const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : ''; const emailHash = email ? createHash('sha256').update(email).digest('hex').slice(0, 16) : 'noemail'; return `${prefix}:${req.ip}:${emailHash}`; }; } /** 3 requests per hour per (IP, email) pair for resending verification emails */ export function createVerificationRateLimit() { return rateLimit({ windowMs: 60 * 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, keyGenerator: keyForEmailAndIp('verify'), store: new RedisStore({ sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise, prefix: 'rl:verify-resend:', }), message: { error: { message: 'Too many verification email requests, please try again later', code: 'VERIFICATION_RATE_LIMIT_EXCEEDED', }, }, }); } /** 3 requests per hour per (IP, email) pair for password reset emails */ export function createResetRateLimit() { return rateLimit({ windowMs: 60 * 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, keyGenerator: keyForEmailAndIp('reset'), store: new RedisStore({ sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise, prefix: 'rl:password-reset:', }), message: { error: { message: 'Too many password reset requests, please try again later', code: 'RESET_RATE_LIMIT_EXCEEDED', }, }, }); }