diff --git a/packages/server/src/routes/federation/handlers/attach.ts b/packages/server/src/routes/federation/handlers/attach.ts index c2f09cbb..8eb557d7 100644 --- a/packages/server/src/routes/federation/handlers/attach.ts +++ b/packages/server/src/routes/federation/handlers/attach.ts @@ -3,7 +3,8 @@ import { config } from '../../../config.js'; import { getDb, getRawDb, schema } from '../../../db/index.js'; import { authenticate } from '../../../utils/auth.js'; import { fetchHomeProfileByHomeId, verifyAttachProofWithPeer } from '../../../utils/federationAttach.js'; -import { buildFederationHeaders, getOurOrigin, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js'; +import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js'; +import { sendSignedJson } from './signedResponse.js'; import { sanitizeUser } from '../../../utils/sanitize.js'; import { collectProfileBroadcastTargetIds } from '../../../utils/userDeletion.js'; import { connectionManager } from '../../../ws/handler.js'; @@ -72,17 +73,8 @@ export function registerAttachRoutes(app: FastifyInstance): void { // 2. Sign every downstream response with the peer's shared secret so the // caller can trust the identity (or the fail-closed verdict) it carries. - const sendSigned = (payload: { valid: false } | { valid: true; homeUserId: string; username: string }): FastifyReply => { - const responseBody = JSON.stringify(payload); - const sigHeaders = buildFederationHeaders(responseBody, peer.hmacSecret, getOurOrigin()); - reply.headers({ - 'X-Federation-Signature': sigHeaders['X-Federation-Signature'], - 'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'], - 'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'], - 'Content-Type': 'application/json', - }); - return reply.code(200).send(responseBody); - }; + const sendSigned = (payload: { valid: false } | { valid: true; homeUserId: string; username: string }): FastifyReply => + sendSignedJson(reply, payload, peer.hmacSecret); // 3. Validate the token shape (64 hex chars, as minted by attach-proof). const rawToken = (request.body as { token?: unknown } | null)?.token; diff --git a/packages/server/src/routes/federation/handlers/relay.ts b/packages/server/src/routes/federation/handlers/relay.ts index b40705b0..d093a0b5 100644 --- a/packages/server/src/routes/federation/handlers/relay.ts +++ b/packages/server/src/routes/federation/handlers/relay.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { config } from '../../../config.js'; import { getDb, getRawDb, schema } from '../../../db/index.js'; -import { buildFederationHeaders, getOurOrigin, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js'; +import { getOurOrigin, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js'; +import { sendSignedJson } from './signedResponse.js'; import { getInstanceId } from '../../../utils/federationEpoch.js'; import { getDmParticipants } from '../../../utils/federationOutbox.js'; import { deleteAttachmentFiles } from '../../../utils/fileCleanup.js'; @@ -285,15 +286,7 @@ export function registerRelayRoutes(app: FastifyInstance): void { } // 4. Sign the response body with the peer's shared secret and return it. - const responseBody = JSON.stringify({ instanceId: getInstanceId() }); - const sigHeaders = buildFederationHeaders(responseBody, peer.hmacSecret, getOurOrigin()); - reply.headers({ - 'X-Federation-Signature': sigHeaders['X-Federation-Signature'], - 'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'], - 'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'], - 'Content-Type': 'application/json', - }); - return reply.code(200).send(responseBody); + return sendSignedJson(reply, { instanceId: getInstanceId() }, peer.hmacSecret); }, ); diff --git a/packages/server/src/routes/federation/handlers/signedResponse.ts b/packages/server/src/routes/federation/handlers/signedResponse.ts new file mode 100644 index 00000000..5aceacec --- /dev/null +++ b/packages/server/src/routes/federation/handlers/signedResponse.ts @@ -0,0 +1,24 @@ +import type { FastifyReply } from 'fastify'; +import { buildFederationHeaders, getOurOrigin } from '../../../utils/federationAuth.js'; + +/** + * Serialize `payload` as JSON and send it as a `200` response signed with the + * peer's shared HMAC secret, so the receiving instance can verify authenticity + * (or trust a fail-closed verdict) of the body it carries. + * + * This is the single definition of how this instance signs S2S responses: the + * body is stringified once and the signature is computed over those exact bytes, + * which are the bytes sent (Content-Type is set explicitly so Fastify does not + * re-serialize and desync the signature). + */ +export function sendSignedJson(reply: FastifyReply, payload: unknown, hmacSecret: string): FastifyReply { + const responseBody = JSON.stringify(payload); + const sigHeaders = buildFederationHeaders(responseBody, hmacSecret, getOurOrigin()); + reply.headers({ + 'X-Federation-Signature': sigHeaders['X-Federation-Signature'], + 'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'], + 'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'], + 'Content-Type': 'application/json', + }); + return reply.code(200).send(responseBody); +} diff --git a/packages/server/src/routes/federation/rateLimits.ts b/packages/server/src/routes/federation/rateLimits.ts index 843b482b..771448b0 100644 --- a/packages/server/src/routes/federation/rateLimits.ts +++ b/packages/server/src/routes/federation/rateLimits.ts @@ -1,138 +1,74 @@ +// ─── 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. - -// ─── In-memory rate limiter for the accept endpoint ────────────────────────── -export const acceptRateBuckets = new Map(); - -export const ACCEPT_RATE_WINDOW_MS = 60_000; - -export const ACCEPT_RATE_MAX = 10; - - -export function isAcceptRateLimited(ip: string): boolean { - const now = Date.now(); - let timestamps = acceptRateBuckets.get(ip); - if (!timestamps) { - timestamps = []; - acceptRateBuckets.set(ip, timestamps); - } - // Prune entries outside the window - const cutoff = now - ACCEPT_RATE_WINDOW_MS; - while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { - timestamps.shift(); - } - if (timestamps.length >= ACCEPT_RATE_MAX) { - return true; - } - timestamps.push(now); - return false; +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; } - -// ─── In-memory rate limiter for the relay endpoint (per-peer) ──────────────── -export const relayRateBuckets = new Map(); - -export const RELAY_RATE_WINDOW_MS = 60_000; - -export const RELAY_RATE_MAX = 90; - - -export 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; -} - - -// ─── In-memory rate limiter for the user-lookup endpoint (per-peer) ────────── -export const lookupRateBuckets = new Map(); - -export const LOOKUP_RATE_WINDOW_MS = 60_000; - -export const LOOKUP_RATE_MAX = 60; - - -export function isLookupRateLimited(peerOrigin: string): boolean { - const now = Date.now(); - let timestamps = lookupRateBuckets.get(peerOrigin); - if (!timestamps) { - timestamps = []; - lookupRateBuckets.set(peerOrigin, timestamps); - } - const cutoff = now - LOOKUP_RATE_WINDOW_MS; - while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { - timestamps.shift(); - } - if (timestamps.length >= LOOKUP_RATE_MAX) return true; - timestamps.push(now); - return false; -} - - -// Test-only export — used by federation.userLookup.test.ts to reset between cases. -export function _resetLookupRateBuckets(): void { - lookupRateBuckets.clear(); -} - - -// ─── In-memory rate limiter for the ensure endpoint (per-user) ───────────── -export const ensureRateBuckets = new Map(); - -export const ENSURE_RATE_WINDOW_MS = 15 * 60_000; // 15 minutes - -export const ENSURE_RATE_MAX = 3; - - -export function isEnsureRateLimited(userId: string): boolean { - const now = Date.now(); - let timestamps = ensureRateBuckets.get(userId); - if (!timestamps) { - timestamps = []; - ensureRateBuckets.set(userId, timestamps); - } - const cutoff = now - ENSURE_RATE_WINDOW_MS; - while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { - timestamps.shift(); - } - if (timestamps.length >= ENSURE_RATE_MAX) { - return true; - } - timestamps.push(now); - return false; -} - -// Clean up stale ensure rate limit buckets every 15 minutes -setInterval(() => { - const cutoff = Date.now() - ENSURE_RATE_WINDOW_MS; - for (const [userId, timestamps] of ensureRateBuckets) { +function createLimiter(windowMs: number, max: number): SlidingWindowLimiter { + const buckets = new Map(); + const prune = (timestamps: number[], cutoff: number): void => { while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { timestamps.shift(); } - if (timestamps.length === 0) { - ensureRateBuckets.delete(userId); - } - } -}, ENSURE_RATE_WINDOW_MS).unref(); + }; + 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; -// ─── In-memory nonce store for replay protection (per-peer) ────────────────── +// 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. -export const NONCE_MAX_AGE_MS = 15 * 60 * 1000; - -export const nonceStore = new Map>(); - +const NONCE_MAX_AGE_MS = 15 * 60 * 1000; +const nonceStore = new Map>(); /** Returns true if the nonce is a duplicate (already seen for this peer). */ export function isNonceDuplicate(peerOrigin: string, nonce: string): boolean { @@ -146,27 +82,15 @@ export function isNonceDuplicate(peerOrigin: string, nonce: string): boolean { return false; } -// Periodically clean stale buckets to prevent unbounded memory growth +// ─── 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(() => { - const cutoff = Date.now() - ACCEPT_RATE_WINDOW_MS; - for (const [ip, timestamps] of acceptRateBuckets) { - while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) { - timestamps.shift(); - } - if (timestamps.length === 0) { - 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); - } - } - // Evict expired nonces + acceptLimiter.sweep(); + relayLimiter.sweep(); const nonceCutoff = Date.now() - NONCE_MAX_AGE_MS; for (const [origin, nonces] of nonceStore) { for (const [nonce, ts] of nonces) { @@ -174,4 +98,4 @@ setInterval(() => { } if (nonces.size === 0) nonceStore.delete(origin); } -}, ACCEPT_RATE_WINDOW_MS).unref(); +}, RATE_WINDOW_MS).unref();