Changeset 75ab393 in Klonkt


Ignore:
Timestamp:
06/25/2026 08:45:02 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
a9900ec
Parents:
49edc72
Message:

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@…>

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/rate-limit.js

    r49edc72 r75ab393  
    2121function clientKey(req) {
    2222  let ip = req.ip || req.socket?.remoteAddress || '';
     23  // Strip a trailing IPv4 port (1.2.3.4:11046 -> 1.2.3.4)
    2324  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  }
    2437  return ip || 'unknown';
    2538}
     
    7083  handler: blockedHandler('pages/auth-register', 'on-special', 'Too many signup attempts.'),
    7184});
     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});
  • src/routes/activitypub.js

    r49edc72 r75ab393  
    1616import db from '../config/database.js';
    1717import AP from '../services/ActivityPubService.js';
     18import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
    1819
    1920const router = express.Router();
     21// Generous per-IP baseline over all /ap/* (reads). The inbox POST gets an
     22// additional, tighter cap inline (it triggers outbound fetches).
     23router.use(apReadLimiter);
    2024let _ver = '1.0.0';
    2125try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
     
    160164  verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
    161165});
    162 router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apJson, async (req, res) => {
     166router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
    163167  try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
    164168  catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
Note: See TracChangeset for help on using the changeset viewer.