From 75504e07c0a827f7e95208d0d9a0580182c929ab Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:33:57 +0200 Subject: [PATCH] fix(federation): add per-peer rate limiting to relay endpoint (FED-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sliding-window rate limiter (30 req/min per peer origin) on POST /api/federation/relay, matching the existing accept endpoint pattern. Returns 429 when exceeded — outbox workers retry with backoff. Check runs before HMAC verification to avoid wasted computation on floods. --- docs/systems/federation.md | 4 +-- packages/server/src/routes/federation.ts | 38 +++++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 47187c8d..515ee8ad 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -371,7 +371,7 @@ interface FederationRelayResponse { ### Inbound Relay Dispatch (`POST /api/federation/relay`) -Body limit: 10 MB. Max 50 events per batch. +Body limit: 10 MB. Max 50 events per batch. Rate-limited to 30 requests/min per peer (sliding window, keyed by `peer.origin`). Returns 429 when exceeded. | eventType | Processor | contextType | |-----------|-----------|-------------| @@ -843,7 +843,7 @@ The third case is the most dangerous -- it looks like the event was queued but n |--------|-----------|-----| | Peer impersonation | `X-Federation-Origin` is verified against `federation_peers.origin` | An attacker who compromises the HMAC secret can impersonate the peer | | User attribution fraud | Authority checks: e.g., `from.homeInstance !== sourceInstance` rejects events where the acting user doesn't belong to the source instance | The check is string equality on `homeInstance` from the payload, which the sender controls. A malicious peer could claim any user belongs to them by setting `homeInstance` to their own origin. | -| Event flooding | Outbox batches limited to 50 events. `/api/federation/peer/accept` rate-limited to 10/min. | No rate limit on `/api/federation/relay` itself. A peer could send unlimited relay requests. | +| Event flooding | Outbox batches limited to 50 events. `/api/federation/peer/accept` rate-limited to 10/min/IP. `/api/federation/relay` rate-limited to 30/min/peer (sliding window, keyed by `peer.origin`). | A sustained attack from multiple compromised peers could still cause load, but individual peers are throttled. | | Replay attacks | 15-minute timestamp window | No nonce -- valid requests can be replayed within the window | | Message content manipulation | None | A compromised peer can forge message content attributed to any user on their instance | diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index a64c1071..b5439a0a 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -98,11 +98,33 @@ function isAcceptRateLimited(ip: string): boolean { return false; } +// ─── In-memory rate limiter for the relay endpoint (per-peer) ──────────────── +const relayRateBuckets = new Map(); +const RELAY_RATE_WINDOW_MS = 60_000; +const RELAY_RATE_MAX = 30; + +function isRelayRateLimited(peerOrigin: string): boolean { + const now = Date.now(); + let timestamps = relayRateBuckets.get(peerOrigin); + if (!timestamps) { + timestamps = []; + relayRateBuckets.set(peerOrigin, timestamps); + } + const cutoff = now - RELAY_RATE_WINDOW_MS; + while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { + timestamps.shift(); + } + if (timestamps.length >= RELAY_RATE_MAX) { + return true; + } + timestamps.push(now); + return false; +} + // Periodically clean stale buckets to prevent unbounded memory growth setInterval(() => { const cutoff = Date.now() - ACCEPT_RATE_WINDOW_MS; for (const [ip, timestamps] of acceptRateBuckets) { - // Remove expired entries while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { timestamps.shift(); } @@ -110,6 +132,15 @@ setInterval(() => { acceptRateBuckets.delete(ip); } } + const relayCutoff = Date.now() - RELAY_RATE_WINDOW_MS; + for (const [origin, timestamps] of relayRateBuckets) { + while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < relayCutoff) { + timestamps.shift(); + } + if (timestamps.length === 0) { + relayRateBuckets.delete(origin); + } + } }, ACCEPT_RATE_WINDOW_MS).unref(); export async function federationRoutes(app: FastifyInstance): Promise { @@ -397,6 +428,11 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 }); } + // 1b. Per-peer rate limiting (before expensive HMAC verification) + if (isRelayRateLimited(peer.origin)) { + return reply.code(429).send({ error: 'Rate limit exceeded', statusCode: 429 }); + } + // Serialize body back to JSON for HMAC verification (we control both sides) const bodyString = JSON.stringify(request.body); if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp)) {