From 991c658f89198d8b8f1ac9be4855374f8c35ff5c Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Tue, 7 Apr 2026 15:36:52 -0500 Subject: [PATCH] feat(server): add rate limiting middleware - Implement sliding window rate limiter with in-memory storage - Three limit tiers: general (100/min), auth (20/min), devicePoll (10/min) - Extract client IP from X-Forwarded-For, X-Real-IP, CF-Connecting-IP, Fly-Client-IP - Health endpoint exempt from rate limiting - Store cleanup function for preventing memory growth - 29 tests covering all functionality --- packages/server/middleware/index.ts | 12 +- packages/server/middleware/rate-limit.test.ts | 373 ++++++++++++++++++ packages/server/middleware/rate-limit.ts | 251 +++++++++++- 3 files changed, 628 insertions(+), 8 deletions(-) create mode 100644 packages/server/middleware/rate-limit.test.ts diff --git a/packages/server/middleware/index.ts b/packages/server/middleware/index.ts index 023601e..914a40f 100644 --- a/packages/server/middleware/index.ts +++ b/packages/server/middleware/index.ts @@ -8,5 +8,15 @@ export { type Identity, type User, } from "./authenticate"; -export { checkRateLimit } from "./rate-limit"; +export { + checkLimit, + checkRateLimit, + cleanupExpiredEntries, + defaultLimits, + getClientIp, + getLimitType, + getRateLimitStoreSize, + type RateLimitConfig, + resetRateLimitStore, +} from "./rate-limit"; export { checkSizeLimit, MAX_BODY_SIZE } from "./size-limit"; diff --git a/packages/server/middleware/rate-limit.test.ts b/packages/server/middleware/rate-limit.test.ts new file mode 100644 index 0000000..6907402 --- /dev/null +++ b/packages/server/middleware/rate-limit.test.ts @@ -0,0 +1,373 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + checkLimit, + checkRateLimit, + cleanupExpiredEntries, + defaultLimits, + getClientIp, + getLimitType, + getRateLimitStoreSize, + type RateLimitConfig, + resetRateLimitStore, +} from "./rate-limit"; + +// Reset store after each test for isolation +afterEach(() => { + resetRateLimitStore(); +}); + +describe("getClientIp", () => { + test("extracts IP from X-Forwarded-For (first entry)", () => { + const request = new Request("http://localhost/test", { + headers: { + "X-Forwarded-For": "203.0.113.195, 70.41.3.18, 150.172.238.178", + }, + }); + expect(getClientIp(request)).toBe("203.0.113.195"); + }); + + test("extracts IP from X-Forwarded-For (single entry)", () => { + const request = new Request("http://localhost/test", { + headers: { "X-Forwarded-For": "203.0.113.195" }, + }); + expect(getClientIp(request)).toBe("203.0.113.195"); + }); + + test("extracts IP from X-Real-IP", () => { + const request = new Request("http://localhost/test", { + headers: { "X-Real-IP": "192.168.1.100" }, + }); + expect(getClientIp(request)).toBe("192.168.1.100"); + }); + + test("extracts IP from CF-Connecting-IP", () => { + const request = new Request("http://localhost/test", { + headers: { "CF-Connecting-IP": "198.51.100.42" }, + }); + expect(getClientIp(request)).toBe("198.51.100.42"); + }); + + test("extracts IP from Fly-Client-IP", () => { + const request = new Request("http://localhost/test", { + headers: { "Fly-Client-IP": "172.16.0.50" }, + }); + expect(getClientIp(request)).toBe("172.16.0.50"); + }); + + test("prefers X-Forwarded-For over other headers", () => { + const request = new Request("http://localhost/test", { + headers: { + "X-Forwarded-For": "203.0.113.195", + "X-Real-IP": "192.168.1.100", + "CF-Connecting-IP": "198.51.100.42", + }, + }); + expect(getClientIp(request)).toBe("203.0.113.195"); + }); + + test("falls back to 127.0.0.1 when no headers present", () => { + const request = new Request("http://localhost/test"); + expect(getClientIp(request)).toBe("127.0.0.1"); + }); + + test("handles empty X-Forwarded-For gracefully", () => { + const request = new Request("http://localhost/test", { + headers: { "X-Forwarded-For": "" }, + }); + // Falls through to default since empty string is falsy after trim + expect(getClientIp(request)).toBe("127.0.0.1"); + }); +}); + +describe("getLimitType", () => { + test("returns 'none' for health check", () => { + expect(getLimitType("/health")).toBe("none"); + }); + + test("returns 'devicePoll' for device token endpoint", () => { + expect(getLimitType("/api/v1/auth/device/token")).toBe("devicePoll"); + }); + + test("returns 'auth' for auth endpoints", () => { + expect(getLimitType("/api/v1/auth/device/code")).toBe("auth"); + expect(getLimitType("/api/v1/auth/device/verify")).toBe("auth"); + expect(getLimitType("/api/v1/auth/callback/google")).toBe("auth"); + }); + + test("returns 'general' for RPC endpoints", () => { + expect(getLimitType("/api/v1/accounts/rpc")).toBe("general"); + expect(getLimitType("/api/v1/engine/rpc")).toBe("general"); + }); + + test("returns 'general' for unknown paths", () => { + expect(getLimitType("/unknown")).toBe("general"); + expect(getLimitType("/api/v2/something")).toBe("general"); + }); +}); + +describe("checkLimit", () => { + const testConfig: RateLimitConfig = { + maxRequests: 5, + windowSec: 1, + }; + + test("allows requests under the limit", () => { + for (let i = 0; i < 5; i++) { + const result = checkLimit("test-key", testConfig); + expect(result.allowed).toBe(true); + expect(result.count).toBe(i + 1); + } + }); + + test("blocks requests over the limit", () => { + // Use up all requests + for (let i = 0; i < 5; i++) { + checkLimit("test-key", testConfig); + } + + // Next request should be blocked + const result = checkLimit("test-key", testConfig); + expect(result.allowed).toBe(false); + expect(result.count).toBe(5); + expect(result.retryAfterSec).toBeGreaterThan(0); + }); + + test("uses separate buckets per key", () => { + // Fill up key1 + for (let i = 0; i < 5; i++) { + checkLimit("key1", testConfig); + } + + // key2 should still work + const result = checkLimit("key2", testConfig); + expect(result.allowed).toBe(true); + }); + + test("resets after window expires", async () => { + // Fill up requests + for (let i = 0; i < 5; i++) { + checkLimit("test-key", testConfig); + } + + // Wait for window to expire + await new Promise((resolve) => setTimeout(resolve, 1100)); + + // Should be allowed again + const result = checkLimit("test-key", testConfig); + expect(result.allowed).toBe(true); + }); +}); + +describe("checkRateLimit", () => { + test("allows requests under the limit", () => { + const request = new Request("http://localhost/api/v1/accounts/rpc", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.1" }, + }); + + const result = checkRateLimit(request); + expect(result).toBeNull(); + }); + + test("returns null for health endpoint (no rate limiting)", () => { + // Even after many requests, health should never be limited + for (let i = 0; i < 200; i++) { + const request = new Request("http://localhost/health", { + headers: { "X-Forwarded-For": "10.0.0.1" }, + }); + const result = checkRateLimit(request); + expect(result).toBeNull(); + } + }); + + test("blocks excessive requests from same IP", () => { + const limits = { + general: { maxRequests: 3, windowSec: 60 }, + auth: { maxRequests: 2, windowSec: 60 }, + devicePoll: { maxRequests: 1, windowSec: 60 }, + }; + + // First 3 requests should pass + for (let i = 0; i < 3; i++) { + const request = new Request("http://localhost/api/v1/accounts/rpc", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.2" }, + }); + const result = checkRateLimit(request, limits); + expect(result).toBeNull(); + } + + // 4th request should be blocked + const request = new Request("http://localhost/api/v1/accounts/rpc", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.2" }, + }); + const result = checkRateLimit(request, limits); + expect(result).not.toBeNull(); + expect(result?.status).toBe(429); + }); + + test("returns 429 with Retry-After header", async () => { + const limits = { + general: { maxRequests: 1, windowSec: 60 }, + auth: { maxRequests: 1, windowSec: 60 }, + devicePoll: { maxRequests: 1, windowSec: 60 }, + }; + + // First request passes + const request1 = new Request("http://localhost/api/v1/accounts/rpc", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.3" }, + }); + checkRateLimit(request1, limits); + + // Second request is rate limited + const request2 = new Request("http://localhost/api/v1/accounts/rpc", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.3" }, + }); + const result = checkRateLimit(request2, limits); + + expect(result).not.toBeNull(); + expect(result?.status).toBe(429); + expect(result?.headers.get("Retry-After")).not.toBeNull(); + + const body = (await result!.json()) as { error: { code: string } }; + expect(body.error.code).toBe("RATE_LIMITED"); + }); + + test("applies stricter limits to auth endpoints", () => { + const limits = { + general: { maxRequests: 100, windowSec: 60 }, + auth: { maxRequests: 2, windowSec: 60 }, + devicePoll: { maxRequests: 1, windowSec: 60 }, + }; + + // Auth endpoint should be limited at 2 + for (let i = 0; i < 2; i++) { + const request = new Request("http://localhost/api/v1/auth/device/code", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.4" }, + }); + expect(checkRateLimit(request, limits)).toBeNull(); + } + + // 3rd auth request blocked + const request = new Request("http://localhost/api/v1/auth/device/code", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.4" }, + }); + expect(checkRateLimit(request, limits)?.status).toBe(429); + }); + + test("applies strictest limits to device token polling", () => { + const limits = { + general: { maxRequests: 100, windowSec: 60 }, + auth: { maxRequests: 20, windowSec: 60 }, + devicePoll: { maxRequests: 1, windowSec: 60 }, + }; + + // First poll allowed + const request1 = new Request("http://localhost/api/v1/auth/device/token", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.5" }, + }); + expect(checkRateLimit(request1, limits)).toBeNull(); + + // Second poll blocked immediately + const request2 = new Request("http://localhost/api/v1/auth/device/token", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.5" }, + }); + expect(checkRateLimit(request2, limits)?.status).toBe(429); + }); + + test("different IPs have separate limits", () => { + const limits = { + general: { maxRequests: 2, windowSec: 60 }, + auth: { maxRequests: 2, windowSec: 60 }, + devicePoll: { maxRequests: 2, windowSec: 60 }, + }; + + // IP1 uses its quota + for (let i = 0; i < 2; i++) { + const request = new Request("http://localhost/api/v1/accounts/rpc", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.10" }, + }); + checkRateLimit(request, limits); + } + + // IP2 should still have its own quota + const request = new Request("http://localhost/api/v1/accounts/rpc", { + method: "POST", + headers: { "X-Forwarded-For": "10.0.0.11" }, + }); + expect(checkRateLimit(request, limits)).toBeNull(); + }); +}); + +describe("store management", () => { + test("getRateLimitStoreSize returns correct count", () => { + expect(getRateLimitStoreSize()).toBe(0); + + checkLimit("key1", { maxRequests: 10, windowSec: 60 }); + expect(getRateLimitStoreSize()).toBe(1); + + checkLimit("key2", { maxRequests: 10, windowSec: 60 }); + expect(getRateLimitStoreSize()).toBe(2); + + // Same key doesn't increase count + checkLimit("key1", { maxRequests: 10, windowSec: 60 }); + expect(getRateLimitStoreSize()).toBe(2); + }); + + test("resetRateLimitStore clears all entries", () => { + checkLimit("key1", { maxRequests: 10, windowSec: 60 }); + checkLimit("key2", { maxRequests: 10, windowSec: 60 }); + expect(getRateLimitStoreSize()).toBe(2); + + resetRateLimitStore(); + expect(getRateLimitStoreSize()).toBe(0); + }); + + test("cleanupExpiredEntries removes old entries", async () => { + // Add entries with very short window + const config = { maxRequests: 10, windowSec: 1 }; + checkLimit("old-key", config); + + expect(getRateLimitStoreSize()).toBe(1); + + // Wait for entries to expire + await new Promise((resolve) => setTimeout(resolve, 1100)); + + // Cleanup with 0 maxAge to remove immediately + const removed = cleanupExpiredEntries(0); + expect(removed).toBe(1); + expect(getRateLimitStoreSize()).toBe(0); + }); + + test("cleanupExpiredEntries keeps recent entries", () => { + checkLimit("recent-key", { maxRequests: 10, windowSec: 60 }); + + const removed = cleanupExpiredEntries(600_000); // 10 minute max age + expect(removed).toBe(0); + expect(getRateLimitStoreSize()).toBe(1); + }); +}); + +describe("defaultLimits", () => { + test("has reasonable default values", () => { + // General: 100 requests per minute + expect(defaultLimits.general.maxRequests).toBe(100); + expect(defaultLimits.general.windowSec).toBe(60); + + // Auth: 20 requests per minute + expect(defaultLimits.auth.maxRequests).toBe(20); + expect(defaultLimits.auth.windowSec).toBe(60); + + // Device poll: 10 requests per minute + expect(defaultLimits.devicePoll.maxRequests).toBe(10); + expect(defaultLimits.devicePoll.windowSec).toBe(60); + }); +}); diff --git a/packages/server/middleware/rate-limit.ts b/packages/server/middleware/rate-limit.ts index 06ebb6e..1029120 100644 --- a/packages/server/middleware/rate-limit.ts +++ b/packages/server/middleware/rate-limit.ts @@ -1,16 +1,253 @@ /** - * Rate limiting middleware. + * Rate limiting middleware using sliding window algorithm. * - * TODO: Implement in chunk 9 + * Tracks requests by client identifier (IP address or authenticated user/identity ID). + * Uses in-memory storage - suitable for single instance, upgrade to Redis for clustering. */ +import { tooManyRequests } from "../util/response"; + +/** + * Rate limit configuration for a specific limit type. + */ +export interface RateLimitConfig { + /** Maximum requests allowed in the window */ + maxRequests: number; + /** Window duration in seconds */ + windowSec: number; +} + /** - * Stub: Check rate limit for request. - * Will track requests by IP (unauthenticated) or identity/user ID (authenticated). + * Entry tracking requests from a single client. + */ +interface RateLimitEntry { + /** Timestamps of requests within the current window */ + timestamps: number[]; + /** Last cleanup time to avoid cleaning on every request */ + lastCleanup: number; +} + +/** + * Rate limit store - maps client identifier to request history. + */ +const store = new Map(); + +/** + * Default rate limits. * - * @returns null if within limit, 429 response if rate limited + * Conservative defaults that can be overridden via environment variables. + */ +export const defaultLimits = { + /** General requests (by IP) */ + general: { + maxRequests: 100, + windowSec: 60, + } satisfies RateLimitConfig, + + /** Auth endpoints - tighter limits to prevent brute force */ + auth: { + maxRequests: 20, + windowSec: 60, + } satisfies RateLimitConfig, + + /** Device code polling - even tighter to prevent abuse */ + devicePoll: { + maxRequests: 10, + windowSec: 60, + } satisfies RateLimitConfig, +}; + +/** + * Get the client IP address from a request. + * + * Checks standard proxy headers in order of preference. + * Falls back to a default for testing/development. + */ +export function getClientIp(request: Request): string { + // X-Forwarded-For: client, proxy1, proxy2 + const forwarded = request.headers.get("X-Forwarded-For"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + + // X-Real-IP (nginx) + const realIp = request.headers.get("X-Real-IP"); + if (realIp) return realIp; + + // CF-Connecting-IP (Cloudflare) + const cfIp = request.headers.get("CF-Connecting-IP"); + if (cfIp) return cfIp; + + // Fly-Client-IP (Fly.io) + const flyIp = request.headers.get("Fly-Client-IP"); + if (flyIp) return flyIp; + + // Fallback for direct connections / testing + return "127.0.0.1"; +} + +/** + * Determine rate limit type based on request path. */ -export function checkRateLimit(_request: Request): Response | null { - // Stub - will be implemented in chunk 9 +export function getLimitType( + path: string, +): "general" | "auth" | "devicePoll" | "none" { + // No rate limiting for health checks + if (path === "/health") { + return "none"; + } + + // Device token polling - strictest limit + if (path === "/api/v1/auth/device/token") { + return "devicePoll"; + } + + // Auth endpoints - stricter limits + if (path.startsWith("/api/v1/auth/")) { + return "auth"; + } + + // Everything else - general limit + return "general"; +} + +/** + * Clean up old timestamps from an entry. + * Only cleans if enough time has passed since last cleanup. + */ +function cleanupEntry(entry: RateLimitEntry, windowMs: number): void { + const now = Date.now(); + + // Only cleanup every second to avoid overhead + if (now - entry.lastCleanup < 1000) { + return; + } + + const cutoff = now - windowMs; + entry.timestamps = entry.timestamps.filter((ts) => ts > cutoff); + entry.lastCleanup = now; +} + +/** + * Check if a request is allowed under the rate limit. + * + * @param key - Unique identifier for the client (IP or user ID) + * @param config - Rate limit configuration + * @returns Object with allowed status, current count, and retry-after seconds + */ +export function checkLimit( + key: string, + config: RateLimitConfig, +): { allowed: boolean; count: number; retryAfterSec: number } { + const now = Date.now(); + const windowMs = config.windowSec * 1000; + const cutoff = now - windowMs; + + let entry = store.get(key); + + if (!entry) { + entry = { timestamps: [], lastCleanup: now }; + store.set(key, entry); + } + + // Clean up old timestamps + cleanupEntry(entry, windowMs); + + // Count requests in current window + const count = entry.timestamps.filter((ts) => ts > cutoff).length; + + if (count >= config.maxRequests) { + // Calculate when the oldest request in the window will expire + const oldestInWindow = entry.timestamps.find((ts) => ts > cutoff); + const retryAfterMs = oldestInWindow + ? oldestInWindow + windowMs - now + : windowMs; + const retryAfterSec = Math.ceil(retryAfterMs / 1000); + + return { allowed: false, count, retryAfterSec }; + } + + // Request allowed - record timestamp + entry.timestamps.push(now); + + return { allowed: true, count: count + 1, retryAfterSec: 0 }; +} + +/** + * Check rate limit for a request. + * + * Uses IP address as the client identifier. For authenticated endpoints, + * per-user rate limiting can be applied separately after authentication. + * + * @param request - The incoming HTTP request + * @param overrideLimits - Optional override for rate limits (useful for testing) + * @returns null if allowed, 429 Response if rate limited + */ +export function checkRateLimit( + request: Request, + overrideLimits?: typeof defaultLimits, +): Response | null { + const url = new URL(request.url); + const path = url.pathname; + const limitType = getLimitType(path); + + // No rate limiting for this path + if (limitType === "none") { + return null; + } + + const limits = overrideLimits ?? defaultLimits; + const config = limits[limitType]; + const clientIp = getClientIp(request); + + // Include limit type in key to have separate buckets per endpoint type + const key = `${limitType}:${clientIp}`; + + const result = checkLimit(key, config); + + if (!result.allowed) { + return tooManyRequests(result.retryAfterSec); + } + return null; } + +/** + * Reset the rate limit store. + * Primarily for testing. + */ +export function resetRateLimitStore(): void { + store.clear(); +} + +/** + * Get current store size. + * Useful for monitoring memory usage. + */ +export function getRateLimitStoreSize(): number { + return store.size; +} + +/** + * Periodically clean up expired entries from the store. + * Should be called on a timer (e.g., every 5 minutes) to prevent memory growth. + * + * @param maxAge - Maximum age in milliseconds for entries (default: 10 minutes) + * @returns Number of entries removed + */ +export function cleanupExpiredEntries(maxAge = 600_000): number { + const now = Date.now(); + let removed = 0; + + for (const [key, entry] of store.entries()) { + // Remove entries that haven't been accessed recently + const newest = Math.max(...entry.timestamps, 0); + if (now - newest > maxAge) { + store.delete(key); + removed++; + } + } + + return removed; +}