source: Klonkt/src/middleware/rate-limit.js@ 824d1d0

main
Last change on this file since 824d1d0 was 58cad21, checked in by Robin <roboburr@…>, 3 weeks ago

OpenWebAuth: PKCS#1 v1.5 zelf uitpakken, met een grens op het aantal pogingen

Node weigert privateDecrypt met RSA_PKCS1_PADDING sinds de mitigatie voor
CVE-2023-46809 (Marvin). Daarmee was gastlogin stuk: vier tests rood, en
in bedrijf een geworpen TypeError midden in de inlogstroom.

De revert-vlag was geen uitweg. Die bestaat alleen op de lijnen 18/20/21
-- Node voegt een security-revert alleen toe aan wat ondersteund was toen
de fix kwam -- en 20 is sinds 30 april 2026 EOL. Node 22+ heeft hem nooit
gehad. FEP-61cf schrijft v1.5 voor, dus OAEP repareert de test en breekt
de interop met Hubzilla. Blijft over: het omhulsel zelf afhalen via
RSA_NO_PADDING.

Dat is precies het stuk dat de CVE veroorzaakte, dus met de zorg erbij:

  • Geen vroege uitgang en geen worp. De scan loopt altijd het hele blok af. Dat is geen echte constant-time -- die krijg je in JavaScript met JIT en GC niet -- maar het haalt het waarneembare verschil weg.
  • Implicit rejection: bij een ongeldig omhulsel een afgeleide waarde in plaats van een fout. DETERMINISTISCH, uit sleutel + ciphertext. Vers willekeurig zou slechter zijn: dezelfde ciphertext twee keer aanbieden gaf dan twee antwoorden, en juist dat verschil wilden we verbergen.
  • Een teller van 20/uur op POST /magic, per SITE-SLUG want daar hangt het sleutelpaar aan. Hij telt ALLE pogingen, niet alleen de mislukte: een teller die alleen faalt meetelt is zelf weer een orakel, en dan staat de vertakking die we bij de ontsleuteling weghaalden aan de achterdeur terug. Een orakel heeft honderdduizenden pogingen nodig; twintig per uur is voor een mens onzichtbaar.

Onderweg bleek een aanname in de bestaande code niet te kloppen: het
commentaar bij decryptToken en bij de test leunden op OpenSSL's eigen
implicit rejection. Die kwam pas in 3.2; Node 20 brengt 3.0.19 mee en
daar WERPT hij. Die meting was dus op een andere machine gedaan dan waar
het draait. Nu maken we het zelf, dus de eigenschap staat vast ongeacht
de OpenSSL eronder.

Suite 1160/1160. Van de drie eigenschappen bijten er twee bij een
controleproef (de PS-ondergrens en het determinisme); de nep-uitkomst
niet, want de andere tak geeft dan een lege string die net zo goed
sneuvelt. Dat staat zo in het commentaar in plaats van dat ik doe alsof
alle drie dragend zijn.

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

  • Property mode set to 100644
File size: 6.2 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// ─── 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.
123export 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.
135export const apInboxLimiter = rateLimit({
136 windowMs: 60 * 1000,
137 max: 120,
138 standardHeaders: true,
139 legacyHeaders: false,
140 keyGenerator: clientKey,
141 validate: { ip: false },
142});
Note: See TracBrowser for help on using the repository browser.