/** * Rate limiters — anti-brute-force. * * In-memory store via express-rate-limit's default. Fine for a single Node * process. If we ever scale to multiple workers, swap to a shared store * (Redis or rate-limit-redis). * * Friendly 429 response handles HTMX requests (returns HX-Redirect to the * form page so the error is shown inline) and full requests (renders a * small message page). */ import rateLimit from 'express-rate-limit'; import { renderPage } from './render.js'; // Behind Cloudflare/Caddy, req.ip can arrive as "1.2.3.4:11046" (IPv4 with // port). express-rate-limit v7 validates the IP and otherwise throws // ERR_ERL_INVALID_IP_ADDRESS — uncaught async → the process crashes (and pm2 // enters a restart loop). Strip a trailing IPv4 port, fall back to the // socket address, and leave IPv6 (multiple colons) untouched. function clientKey(req) { let ip = req.ip || req.socket?.remoteAddress || ''; if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0]; return ip || 'unknown'; } function blockedHandler(viewName, bodyClass, friendlyMsg) { return (req, res, next, options) => { const message = `${friendlyMsg} Try again in a few minutes.`; if (req.headers['hx-request'] === 'true') { // HTMX: surface the error in the form's error slot via partial render. return renderPage(req, res, viewName, { pageTitle: 'Too many attempts', bodyClass, error: message, username: req.body?.username || '', email: req.body?.email || '', }); } res.status(options.statusCode).type('html').send(`
`); }; } export const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 min max: 5, // 5 attempts per IP per window standardHeaders: true, legacyHeaders: false, keyGenerator: clientKey, validate: { ip: false }, // Only count failed attempts. Successful logins don't burn the budget. skipSuccessfulRequests: true, handler: blockedHandler('pages/auth-login', 'on-special', 'Too many login attempts.'), }); export const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 5, // 5 signups per IP per hour standardHeaders: true, legacyHeaders: false, keyGenerator: clientKey, validate: { ip: false }, skipSuccessfulRequests: false, // any attempt counts (registration spam is the concern) handler: blockedHandler('pages/auth-register', 'on-special', 'Too many signup attempts.'), });