Files
backspace/packages/server/src/routes/federation/rateLimits.ts
T
TheZwiss c79bf91398 refactor(server): dedupe federation rate limiters and response signing (#10)
Phase C cleanup follow-up to the routes/federation split (#9). Behavior-
preserving; full server suite (790 tests) green.

A) rateLimits.ts: the four near-identical sliding-window limiters
   (accept/relay/lookup/ensure) and their duplicated prune loops collapse
   into one createLimiter(windowMs, max) factory. Per-call and periodic-
   sweep semantics are preserved exactly, including that lookup buckets are
   pruned per-call but never swept (unchanged from before). 177 -> 101 lines.

B) Extract sendSignedJson(reply, payload, hmacSecret) — the single
   definition of how this instance signs an S2S JSON response — and use it
   in the /epoch and /verify-attach-proof|reattach handlers, replacing two
   copies of the build-headers-and-send boilerplate.
2026-07-10 02:23:14 +02:00

102 lines
4.2 KiB
TypeScript

// ─── In-memory sliding-window rate limiters (federation S2S endpoints) ───────
//
// Each limiter keeps a per-key ring of request timestamps inside a fixed window.
// `limited(key)` prunes that key's expired entries, then returns true (without
// recording a hit) once the key is at capacity. `sweep()` prunes every key and
// drops emptied buckets to bound memory; it runs on a timer, not per request.
interface SlidingWindowLimiter {
/** True if `key` is already at capacity for the current window; otherwise records the hit and returns false. */
limited(key: string): boolean;
/** Prune expired timestamps across all keys and drop now-empty buckets. */
sweep(): void;
/** Underlying buckets — exposed only so tests can reset state. */
readonly buckets: Map<string, number[]>;
}
function createLimiter(windowMs: number, max: number): SlidingWindowLimiter {
const buckets = new Map<string, number[]>();
const prune = (timestamps: number[], cutoff: number): void => {
while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) {
timestamps.shift();
}
};
return {
buckets,
limited(key: string): boolean {
const now = Date.now();
let timestamps = buckets.get(key);
if (!timestamps) {
timestamps = [];
buckets.set(key, timestamps);
}
prune(timestamps, now - windowMs);
if (timestamps.length >= max) return true;
timestamps.push(now);
return false;
},
sweep(): void {
const cutoff = Date.now() - windowMs;
for (const [key, timestamps] of buckets) {
prune(timestamps, cutoff);
if (timestamps.length === 0) buckets.delete(key);
}
},
};
}
const RATE_WINDOW_MS = 60_000;
const ENSURE_WINDOW_MS = 15 * 60_000;
// accept: per source IP · relay & user-lookup: per peer origin · ensure: per user
const acceptLimiter = createLimiter(RATE_WINDOW_MS, 10);
const relayLimiter = createLimiter(RATE_WINDOW_MS, 90);
const lookupLimiter = createLimiter(RATE_WINDOW_MS, 60);
const ensureLimiter = createLimiter(ENSURE_WINDOW_MS, 3);
export const isAcceptRateLimited = (ip: string): boolean => acceptLimiter.limited(ip);
export const isRelayRateLimited = (peerOrigin: string): boolean => relayLimiter.limited(peerOrigin);
export const isLookupRateLimited = (peerOrigin: string): boolean => lookupLimiter.limited(peerOrigin);
export const isEnsureRateLimited = (userId: string): boolean => ensureLimiter.limited(userId);
// Test-only export — used by federation.userLookup.test.ts to reset between cases.
export function _resetLookupRateBuckets(): void {
lookupLimiter.buckets.clear();
}
// ─── Nonce store for replay protection (per-peer) ────────────────────────────
// Maps peerOrigin → (nonce → insertion timestamp). Nonces are evicted after
// NONCE_MAX_AGE_MS (15 min) to match the HMAC timestamp window.
const NONCE_MAX_AGE_MS = 15 * 60 * 1000;
const nonceStore = new Map<string, Map<string, number>>();
/** Returns true if the nonce is a duplicate (already seen for this peer). */
export function isNonceDuplicate(peerOrigin: string, nonce: string): boolean {
let peerNonces = nonceStore.get(peerOrigin);
if (!peerNonces) {
peerNonces = new Map();
nonceStore.set(peerOrigin, peerNonces);
}
if (peerNonces.has(nonce)) return true;
peerNonces.set(nonce, Date.now());
return false;
}
// ─── Periodic cleanup to bound memory ────────────────────────────────────────
// Ensure buckets sweep on their own (long) window. Accept + relay buckets and
// nonce eviction share the short window. Lookup buckets are pruned per-call only
// (never swept here) — preserving the original behavior.
setInterval(() => ensureLimiter.sweep(), ENSURE_WINDOW_MS).unref();
setInterval(() => {
acceptLimiter.sweep();
relayLimiter.sweep();
const nonceCutoff = Date.now() - NONCE_MAX_AGE_MS;
for (const [origin, nonces] of nonceStore) {
for (const [nonce, ts] of nonces) {
if (ts < nonceCutoff) nonces.delete(nonce);
}
if (nonces.size === 0) nonceStore.delete(origin);
}
}, RATE_WINDOW_MS).unref();