fix(federation): add per-peer rate limiting to relay endpoint (FED-007)

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.
This commit is contained in:
Jannis Braun
2026-03-31 17:33:57 +02:00
parent 2cac39a460
commit 75504e07c0
2 changed files with 39 additions and 3 deletions
+37 -1
View File
@@ -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<string, number[]>();
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<void> {
@@ -397,6 +428,11 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
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)) {