source: Klonkt/src/middleware/rate-limit.js@ 8d054ca

main
Last change on this file since 8d054ca was 75ab393, checked in by Robin Genis <roboburr@…>, 3 months ago

feat(ratelimit): cap fediverse endpoints per IP; key IPv6 limiters by /64

Adds a generous per-IP baseline limiter across /ap/* reads (300/min) and a
tighter cap on the inbox POST (120/min), since each inbox delivery triggers an
outbound actor fetch. clientKey now collapses IPv6 to its /64 prefix so the
login/register/AP limiters can't be sidestepped by rotating addresses within
one allocation. Generous thresholds — real federation never trips them.

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

  • Property mode set to 100644
File size: 4.6 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// Behind Cloudflare/Caddy, req.ip can arrive as "1.2.3.4:11046" (IPv4 with
17// port). express-rate-limit v7 validates the IP and otherwise throws
18// ERR_ERL_INVALID_IP_ADDRESS — uncaught async → the process crashes (and pm2
19// enters a restart loop). Strip a trailing IPv4 port, fall back to the
20// socket address, and leave IPv6 (multiple colons) untouched.
21function clientKey(req) {
22 let ip = req.ip || req.socket?.remoteAddress || '';
23 // Strip a trailing IPv4 port (1.2.3.4:11046 -> 1.2.3.4)
24 if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0];
25 // IPv6-mapped IPv4 (::ffff:1.2.3.4) -> the plain IPv4
26 const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
27 if (mapped) return mapped[1];
28 // Real IPv6: key on the /64 network prefix, not the full address. A single
29 // user/allocation is usually a whole /64, so this stops an attacker from
30 // getting a fresh budget by rotating addresses within their own range.
31 if (ip.includes(':')) {
32 const left = ip.includes('::') ? ip.split('::')[0] : ip;
33 const groups = left.split(':').filter(Boolean);
34 while (groups.length < 4) groups.push('0');
35 return groups.slice(0, 4).join(':') + '::/64';
36 }
37 return ip || 'unknown';
38}
39
40function blockedHandler(viewName, bodyClass, friendlyMsg) {
41 return (req, res, next, options) => {
42 const message = `${friendlyMsg} Try again in a few minutes.`;
43 if (req.headers['hx-request'] === 'true') {
44 // HTMX: surface the error in the form's error slot via partial render.
45 return renderPage(req, res, viewName, {
46 pageTitle: 'Too many attempts',
47 bodyClass,
48 error: message,
49 username: req.body?.username || '',
50 email: req.body?.email || '',
51 });
52 }
53 res.status(options.statusCode).type('html').send(`
54 <div style="font-family:system-ui;max-width:520px;margin:4rem auto;padding:2rem;text-align:center;">
55 <h1 style="font-size:2rem;margin:0 0 0.5rem;">Too many attempts</h1>
56 <p>${message}</p>
57 <p><a href="/" style="color:#c2410c;">&larr; Home</a></p>
58 </div>
59 `);
60 };
61}
62
63export const loginLimiter = rateLimit({
64 windowMs: 15 * 60 * 1000, // 15 min
65 max: 5, // 5 attempts per IP per window
66 standardHeaders: true,
67 legacyHeaders: false,
68 keyGenerator: clientKey,
69 validate: { ip: false },
70 // Only count failed attempts. Successful logins don't burn the budget.
71 skipSuccessfulRequests: true,
72 handler: blockedHandler('pages/auth-login', 'on-special', 'Too many login attempts.'),
73});
74
75export const registerLimiter = rateLimit({
76 windowMs: 60 * 60 * 1000, // 1 hour
77 max: 5, // 5 signups per IP per hour
78 standardHeaders: true,
79 legacyHeaders: false,
80 keyGenerator: clientKey,
81 validate: { ip: false },
82 skipSuccessfulRequests: false, // any attempt counts (registration spam is the concern)
83 handler: blockedHandler('pages/auth-register', 'on-special', 'Too many signup attempts.'),
84});
85
86// ─── Fediverse (/ap/*) ────────────────────────────────────────────
87// These endpoints are hit by REMOTE SERVERS, not browsers, so the default
88// plain-text 429 is the right response (no HTML page). Deliberately generous:
89// legitimate federation from one instance never comes close, but a flood from
90// a single IP is capped. Per-IP via the same /64-aware clientKey.
91
92// Baseline read cap across all /ap/* (actor, outbox, notes, webfinger, …).
93// 5 req/sec per IP — far above any real Mastodon polling.
94export const apReadLimiter = rateLimit({
95 windowMs: 60 * 1000,
96 max: 300,
97 standardHeaders: true,
98 legacyHeaders: false,
99 keyGenerator: clientKey,
100 validate: { ip: false },
101});
102
103// Inbox POSTs each trigger an outbound actor fetch (signature verify) → cap the
104// amplification/queue-inflation a single source can drive. 120/min/IP is still
105// generous for a small site's inbound federation; bump if a busy instance trips it.
106export const apInboxLimiter = rateLimit({
107 windowMs: 60 * 1000,
108 max: 120,
109 standardHeaders: true,
110 legacyHeaders: false,
111 keyGenerator: clientKey,
112 validate: { ip: false },
113});
Note: See TracBrowser for help on using the repository browser.