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:
@@ -371,7 +371,7 @@ interface FederationRelayResponse {
|
|||||||
|
|
||||||
### Inbound Relay Dispatch (`POST /api/federation/relay`)
|
### 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 |
|
| 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 |
|
| 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. |
|
| 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 |
|
| 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 |
|
| Message content manipulation | None | A compromised peer can forge message content attributed to any user on their instance |
|
||||||
|
|
||||||
|
|||||||
@@ -98,11 +98,33 @@ function isAcceptRateLimited(ip: string): boolean {
|
|||||||
return false;
|
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
|
// Periodically clean stale buckets to prevent unbounded memory growth
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const cutoff = Date.now() - ACCEPT_RATE_WINDOW_MS;
|
const cutoff = Date.now() - ACCEPT_RATE_WINDOW_MS;
|
||||||
for (const [ip, timestamps] of acceptRateBuckets) {
|
for (const [ip, timestamps] of acceptRateBuckets) {
|
||||||
// Remove expired entries
|
|
||||||
while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) {
|
while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) {
|
||||||
timestamps.shift();
|
timestamps.shift();
|
||||||
}
|
}
|
||||||
@@ -110,6 +132,15 @@ setInterval(() => {
|
|||||||
acceptRateBuckets.delete(ip);
|
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();
|
}, ACCEPT_RATE_WINDOW_MS).unref();
|
||||||
|
|
||||||
export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
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 });
|
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)
|
// Serialize body back to JSON for HMAC verification (we control both sides)
|
||||||
const bodyString = JSON.stringify(request.body);
|
const bodyString = JSON.stringify(request.body);
|
||||||
if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp)) {
|
if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user