| [7bc636b] | 1 | /**
|
|---|
| 2 | * Rate limiters — anti-brute-force.
|
|---|
| 3 | *
|
|---|
| 4 | * In-memory store via express-rate-limit's default. Fine for a single Node
|
|---|
| 5 | * process. If we ever scale to multiple workers, swap to a shared store
|
|---|
| 6 | * (Redis or rate-limit-redis).
|
|---|
| 7 | *
|
|---|
| 8 | * Friendly 429 response handles HTMX requests (returns HX-Redirect to the
|
|---|
| 9 | * form page so the error is shown inline) and full requests (renders a
|
|---|
| 10 | * small message page).
|
|---|
| 11 | */
|
|---|
| 12 |
|
|---|
| 13 | import rateLimit from 'express-rate-limit';
|
|---|
| 14 | import { renderPage } from './render.js';
|
|---|
| 15 |
|
|---|
| 16 | function blockedHandler(viewName, bodyClass, friendlyMsg) {
|
|---|
| 17 | return (req, res, next, options) => {
|
|---|
| 18 | const message = `${friendlyMsg} Try again in a few minutes.`;
|
|---|
| 19 | if (req.headers['hx-request'] === 'true') {
|
|---|
| 20 | // HTMX: surface the error in the form's error slot via partial render.
|
|---|
| 21 | return renderPage(req, res, viewName, {
|
|---|
| 22 | pageTitle: 'Too many attempts',
|
|---|
| 23 | bodyClass,
|
|---|
| 24 | error: message,
|
|---|
| 25 | username: req.body?.username || '',
|
|---|
| 26 | email: req.body?.email || '',
|
|---|
| 27 | });
|
|---|
| 28 | }
|
|---|
| 29 | res.status(options.statusCode).type('html').send(`
|
|---|
| 30 | <div style="font-family:system-ui;max-width:520px;margin:4rem auto;padding:2rem;text-align:center;">
|
|---|
| 31 | <h1 style="font-size:2rem;margin:0 0 0.5rem;">Too many attempts</h1>
|
|---|
| 32 | <p>${message}</p>
|
|---|
| 33 | <p><a href="/" style="color:#c2410c;">← Home</a></p>
|
|---|
| 34 | </div>
|
|---|
| 35 | `);
|
|---|
| 36 | };
|
|---|
| 37 | }
|
|---|
| 38 |
|
|---|
| 39 | export const loginLimiter = rateLimit({
|
|---|
| 40 | windowMs: 15 * 60 * 1000, // 15 min
|
|---|
| 41 | max: 5, // 5 attempts per IP per window
|
|---|
| 42 | standardHeaders: true,
|
|---|
| 43 | legacyHeaders: false,
|
|---|
| 44 | // Only count failed attempts. Successful logins don't burn the budget.
|
|---|
| 45 | skipSuccessfulRequests: true,
|
|---|
| 46 | handler: blockedHandler('pages/auth-login', 'on-special', 'Too many login attempts.'),
|
|---|
| 47 | });
|
|---|
| 48 |
|
|---|
| 49 | export const registerLimiter = rateLimit({
|
|---|
| 50 | windowMs: 60 * 60 * 1000, // 1 hour
|
|---|
| 51 | max: 5, // 5 signups per IP per hour
|
|---|
| 52 | standardHeaders: true,
|
|---|
| 53 | legacyHeaders: false,
|
|---|
| 54 | skipSuccessfulRequests: false, // any attempt counts (registration spam is the concern)
|
|---|
| 55 | handler: blockedHandler('pages/auth-register', 'on-special', 'Too many signup attempts.'),
|
|---|
| 56 | });
|
|---|