| 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 | // 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.
|
|---|
| 21 | function 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 |
|
|---|
| 40 | function 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;">← Home</a></p>
|
|---|
| 58 | </div>
|
|---|
| 59 | `);
|
|---|
| 60 | };
|
|---|
| 61 | }
|
|---|
| 62 |
|
|---|
| 63 | export 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 |
|
|---|
| 75 | export 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.
|
|---|
| 94 | export 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 | // ─── OpenWebAuth /magic ───────────────────────────────────────────
|
|---|
| 104 | // Elke poging doet EEN RSA-ontsleuteling met de actorsleutel van een site. Dat
|
|---|
| 105 | // is precies de vorm waar een Bleichenbacher/Marvin-orakel op draait: veel
|
|---|
| 106 | // aangepaste ciphertexts, en uit de antwoorden de sleutel afleiden. De
|
|---|
| 107 | // ontsleuteling zelf is daartegen gehard (implicit rejection in
|
|---|
| 108 | // OpenWebAuthService.decryptToken), maar echte constant-time code bestaat niet
|
|---|
| 109 | // in JavaScript. Een grens op het AANTAL pogingen doet daarom het zware werk:
|
|---|
| 110 | // een orakel heeft er honderdduizenden nodig.
|
|---|
| 111 | //
|
|---|
| 112 | // TELT ALLE POGINGEN, niet alleen de mislukte. Een teller die alleen faalt
|
|---|
| 113 | // meetelt is zelf weer een orakel -- dan leest een aanvaller aan het knijpen af
|
|---|
| 114 | // of zijn padding klopte, en is de vertakking die we bij de ontsleuteling
|
|---|
| 115 | // weghaalden aan de achterdeur terug.
|
|---|
| 116 | //
|
|---|
| 117 | // Per SITE-SLUG, want dat is wat een sleutelpaar heeft (getOrCreateKeys(slug)):
|
|---|
| 118 | // de grens hoort bij de sleutel die beschermd wordt, niet bij het IP van de
|
|---|
| 119 | // eigenaar of bij de doel-host die de aanvaller zelf kiest.
|
|---|
| 120 | //
|
|---|
| 121 | // Twintig per uur is voor een mens onzichtbaar -- je klikt een handvol keer per
|
|---|
| 122 | // dag naar een andere site -- en voor een orakel dodelijk.
|
|---|
| 123 | export const owaMagicLimiter = rateLimit({
|
|---|
| 124 | windowMs: 60 * 60 * 1000,
|
|---|
| 125 | max: 20,
|
|---|
| 126 | standardHeaders: true,
|
|---|
| 127 | legacyHeaders: false,
|
|---|
| 128 | keyGenerator: (req) => 'owa:' + String((req.body && req.body.slug) || (req.session && req.session.user && req.session.user.id) || 'onbekend'),
|
|---|
| 129 | validate: { ip: false },
|
|---|
| 130 | });
|
|---|
| 131 |
|
|---|
| 132 | // Inbox POSTs each trigger an outbound actor fetch (signature verify) → cap the
|
|---|
| 133 | // amplification/queue-inflation a single source can drive. 120/min/IP is still
|
|---|
| 134 | // generous for a small site's inbound federation; bump if a busy instance trips it.
|
|---|
| 135 | export const apInboxLimiter = rateLimit({
|
|---|
| 136 | windowMs: 60 * 1000,
|
|---|
| 137 | max: 120,
|
|---|
| 138 | standardHeaders: true,
|
|---|
| 139 | legacyHeaders: false,
|
|---|
| 140 | keyGenerator: clientKey,
|
|---|
| 141 | validate: { ip: false },
|
|---|
| 142 | });
|
|---|