import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; import { Request } from 'express'; // First rate limiting in the codebase. The password-reset endpoints need it // most: without one, anyone can make the server send unlimited mail to any // address. Login and registration are the obvious next candidates. // // The default in-memory store suits a single-instance deployment, which this // is. Running more than one app container would need a shared store, or each // instance would enforce its own separate allowance. const WINDOW_MS = 15 * 60 * 1000; const MAX_REQUESTS = 5; // Keyed on caller *and* address rather than caller alone. Keying on IP only // would let one person's reset attempts lock out everyone behind the same // NAT or reverse proxy — and everything here arrives via Nginx Proxy Manager, // so a great many customers share an apparent address. // // This key only makes sense on a request that carries an email. Applying the // same limiter to an endpoint without one collapses every caller into a single // `ip:` bucket, which is a shared allowance rather than a per-caller one. // // The caller half goes through ipKeyGenerator rather than using req.ip raw. // A raw IPv6 address is the full 128 bits, but a residential IPv6 customer is // delegated an entire prefix and can source every request from a different // address inside it for free — so keyed on the exact address this limiter // counted each request as a new caller and never bound at all. That is not a // small miss: this limiter is the only thing stopping anyone making the server // send unlimited mail to any address they choose. express-rate-limit reported // it as ERR_ERL_KEY_GEN_IPV6 on every boot; see #84. // // The helper's default groups IPv6 by /56 rather than /64. That is the // deliberate choice: /56 covers a whole delegated site, so an attacker cannot // escape the bucket by moving within their own allocation. It does mean // several households behind one delegation share an allowance — acceptable // here only because the key also contains the email address, so they collide // just when targeting the same account. IPv4 is returned unchanged. // // Exported for the unit test. The limiter's own allowance is not worth // asserting in a test — its store is process-wide, so exhausting it leaks into // every later test from the same address — but the key function is pure and // is where the bug actually was. export function keyByCallerAndEmail(req: Request): string { const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : ''; return `${ipKeyGenerator(req.ip ?? '')}:${email}`; } export const passwordResetRequestLimiter = rateLimit({ windowMs: WINDOW_MS, limit: MAX_REQUESTS, keyGenerator: keyByCallerAndEmail, standardHeaders: 'draft-7', legacyHeaders: false, message: { error: 'too many attempts, please try again later' } }); // Client error reports carry no email, so this one is keyed on the caller // alone — deliberately not reusing passwordResetRequestLimiter, whose comment // above explains why its key is wrong for an endpoint without an email. // // `trust proxy` is set in app.ts, so `req.ip` is the real client address from // X-Forwarded-For rather than Nginx Proxy Manager's, making this a per-customer // allowance rather than one shared by everybody behind the proxy. // // Generous, because hitting the limit is harmless: the reporter ignores the // response either way. It exists so a render loop cannot fill the log. const CLIENT_ERROR_WINDOW_MS = 15 * 60 * 1000; const CLIENT_ERROR_MAX_REQUESTS = 30; export const clientErrorLimiter = rateLimit({ windowMs: CLIENT_ERROR_WINDOW_MS, limit: CLIENT_ERROR_MAX_REQUESTS, standardHeaders: 'draft-7', legacyHeaders: false, message: { error: 'too many reports' } });