source: Klonkt/src/middleware/rate-limit.js@ 91094a4

main
Last change on this file since 91094a4 was c88783e, checked in by roboburr <roboburr@…>, 3 months ago

fix: rate limiter crash behind Cloudflare/Caddy (req.ip with port)

express-rate-limit v7 validates req.ip and throws ERR_ERL_INVALID_IP_ADDRESS
when the IP contains a port (e.g. "104.23.170.162:11046" via the proxy chain).
Uncaught async -> process crashes -> pm2 restart loop -> EADDRINUSE on :3000.

Fix: custom keyGenerator (clientKey) that strips a trailing IPv4 port + falls
back to the socket, and validate:{ip:false} on both limiters so a non-standard
IP format can never crash the process again.

Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 2.8 KB
Line 
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
13import rateLimit from 'express-rate-limit';
14import { renderPage } from './render.js';
15
16// Achter Cloudflare/Caddy kan req.ip binnenkomen als "1.2.3.4:11046" (IPv4 met
17// poort). express-rate-limit v7 valideert het IP en gooit anders
18// ERR_ERL_INVALID_IP_ADDRESS — onafgevangen async → het proces crasht (en pm2
19// loopt in een restart-loop). Strip een trailing IPv4-poort, val terug op de
20// socket, en laat IPv6 (meerdere dubbele punten) ongemoeid.
21function clientKey(req) {
22 let ip = req.ip || req.socket?.remoteAddress || '';
23 if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0];
24 return ip || 'unknown';
25}
26
27function blockedHandler(viewName, bodyClass, friendlyMsg) {
28 return (req, res, next, options) => {
29 const message = `${friendlyMsg} Try again in a few minutes.`;
30 if (req.headers['hx-request'] === 'true') {
31 // HTMX: surface the error in the form's error slot via partial render.
32 return renderPage(req, res, viewName, {
33 pageTitle: 'Too many attempts',
34 bodyClass,
35 error: message,
36 username: req.body?.username || '',
37 email: req.body?.email || '',
38 });
39 }
40 res.status(options.statusCode).type('html').send(`
41 <div style="font-family:system-ui;max-width:520px;margin:4rem auto;padding:2rem;text-align:center;">
42 <h1 style="font-size:2rem;margin:0 0 0.5rem;">Too many attempts</h1>
43 <p>${message}</p>
44 <p><a href="/" style="color:#c2410c;">&larr; Home</a></p>
45 </div>
46 `);
47 };
48}
49
50export const loginLimiter = rateLimit({
51 windowMs: 15 * 60 * 1000, // 15 min
52 max: 5, // 5 attempts per IP per window
53 standardHeaders: true,
54 legacyHeaders: false,
55 keyGenerator: clientKey,
56 validate: { ip: false },
57 // Only count failed attempts. Successful logins don't burn the budget.
58 skipSuccessfulRequests: true,
59 handler: blockedHandler('pages/auth-login', 'on-special', 'Too many login attempts.'),
60});
61
62export const registerLimiter = rateLimit({
63 windowMs: 60 * 60 * 1000, // 1 hour
64 max: 5, // 5 signups per IP per hour
65 standardHeaders: true,
66 legacyHeaders: false,
67 keyGenerator: clientKey,
68 validate: { ip: false },
69 skipSuccessfulRequests: false, // any attempt counts (registration spam is the concern)
70 handler: blockedHandler('pages/auth-register', 'on-special', 'Too many signup attempts.'),
71});
Note: See TracBrowser for help on using the repository browser.