// Per-client-IP sliding-window rate limiter for /v1/* API endpoints. // Tracks request counts per key and returns 429 when the limit is exceeded. // Follows the same sliding-window pattern as gateway/src/auth-rate-limiter.ts. import { getConfigReadOnly } from "../../config/loader.js"; import { DEFAULT_AUTHENTICATED_API_MAX_REQUESTS_PER_MINUTE } from "../../config/schemas/api-rate-limit.js"; import { getLogger } from "../../util/logger.js"; import type { HttpErrorResponse } from "../http-errors.js"; import { isLoopbackAddress, isPrivateAddress } from "./auth.js"; const log = getLogger("rate-limiter"); const DEFAULT_MAX_REQUESTS = DEFAULT_AUTHENTICATED_API_MAX_REQUESTS_PER_MINUTE; const DEFAULT_WINDOW_MS = 60_000; // 60 seconds const MAX_TRACKED_TOKENS = 10_000; // Higher budget for authenticated loopback clients. Loopback means the // request originated on the daemon's own host (desktop app, CLI, local // scripts) — proxied remote traffic resolves to its forwarded client IP // instead (see extractClientIp). Local clients legitimately burst far // beyond the remote budget: a cold sidebar load at thousands of // conversations pages through hundreds of GETs in a few seconds. const LOOPBACK_MAX_REQUESTS = 1200; // Lower limit for unauthenticated (IP-based) requests to reduce abuse surface. const DEFAULT_IP_MAX_REQUESTS = 20; const DEFAULT_IP_WINDOW_MS = 60_000; const MAX_TRACKED_IPS = 50_000; interface RequestEntry { timestamp: number; path: string; } class TokenRateLimiter { private requests = new Map(); private maxRequests: number; private readonly windowMs: number; private readonly maxTrackedKeys: number; /** * The budget is resolved once and stored, not read per check — a config * edit is pushed in via `setMaxRequests()` (see the config watcher) rather * than doing config-loader work on the per-request hot path. When * `maxRequests` is omitted, the authenticated-remote budget is read from * workspace configuration. */ constructor( maxRequests?: number, windowMs = DEFAULT_WINDOW_MS, maxTrackedKeys = MAX_TRACKED_TOKENS, ) { this.maxRequests = maxRequests ?? resolveAuthenticatedApiMaxRequests(); this.windowMs = windowMs; this.maxTrackedKeys = maxTrackedKeys; } /** Update the per-minute budget in place (e.g. after a config reload). */ setMaxRequests(maxRequests: number): void { this.maxRequests = maxRequests; } /** * Check whether the request should be allowed and record it. * Returns rate limit metadata for response headers. */ check(key: string, path?: string): RateLimitResult { const now = Date.now(); const maxRequests = this.maxRequests; let entries = this.requests.get(key); if (!entries) { if (this.requests.size >= this.maxTrackedKeys) { this.evictStale(now); if (this.requests.size >= this.maxTrackedKeys) { const oldest = this.requests.keys().next().value; if (oldest !== undefined) { this.requests.delete(oldest); } } } entries = []; this.requests.set(key, entries); } const cutoff = now - this.windowMs; // Remove expired entries from the front while (entries.length > 0 && entries[0].timestamp <= cutoff) { entries.shift(); } const remaining = Math.max(0, maxRequests - entries.length); const resetAt = entries.length > 0 ? Math.ceil((entries[0].timestamp + this.windowMs) / 1000) : Math.ceil((now + this.windowMs) / 1000); if (entries.length >= maxRequests) { return { allowed: false, limit: maxRequests, remaining: 0, resetAt, }; } entries.push({ timestamp: now, path: path ?? "unknown" }); return { allowed: true, limit: maxRequests, remaining: remaining - 1, resetAt, }; } /** * Return a count of recent requests grouped by path for the given key. * Sorted descending by count. Useful for diagnosing which endpoints * are consuming the rate limit budget. */ getRecentPathCounts(key: string): Array<{ path: string; count: number }> { const entries = this.requests.get(key); if (!entries || entries.length === 0) { return []; } const now = Date.now(); const cutoff = now - this.windowMs; const counts = new Map(); for (const entry of entries) { if (entry.timestamp > cutoff) { counts.set(entry.path, (counts.get(entry.path) ?? 0) + 1); } } return Array.from(counts.entries()) .map(([path, count]) => ({ path, count })) .sort((a, b) => b.count - a.count); } private evictStale(now: number): void { const cutoff = now - this.windowMs; for (const [key, entries] of this.requests) { while (entries.length > 0 && entries[0].timestamp <= cutoff) { entries.shift(); } if (entries.length === 0) { this.requests.delete(key); } } } } export interface RateLimitResult { allowed: boolean; limit: number; remaining: number; /** Unix timestamp (seconds) when the window resets. */ resetAt: number; } /** Build standard rate limit headers from a check result. */ export function rateLimitHeaders( result: RateLimitResult, ): Record { return { "X-RateLimit-Limit": String(result.limit), "X-RateLimit-Remaining": String(result.remaining), "X-RateLimit-Reset": String(result.resetAt), }; } /** Return a 429 response with rate limit headers and a Retry-After hint. */ export function rateLimitResponse( result: RateLimitResult, diagnostics?: { clientIp: string; deniedPath: string; limiterKind: "authenticated" | "unauthenticated"; pathCounts: Array<{ path: string; count: number }>; }, ): Response { const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000)); if (diagnostics) { log.warn( { clientIp: diagnostics.clientIp, deniedPath: diagnostics.deniedPath, limiterKind: diagnostics.limiterKind, limit: result.limit, retryAfterSec: retryAfter, recentRequests: diagnostics.pathCounts, }, `Rate limited ${diagnostics.limiterKind} request: ${diagnostics.deniedPath} (${result.limit} req/min exceeded)`, ); } const body: HttpErrorResponse = { error: { code: "RATE_LIMITED", message: "Too Many Requests" }, }; return Response.json(body, { status: 429, headers: { ...rateLimitHeaders(result), "Retry-After": String(retryAfter), }, }); } /** * Resolve the per-minute budget for authenticated remote (non-loopback) * clients from workspace configuration, falling back to the built-in default * if the config is unavailable (e.g. read before the workspace is ready). * Uses the read-only accessor so no config read ever writes to disk. */ function resolveAuthenticatedApiMaxRequests(): number { try { return getConfigReadOnly().apiRateLimit.authenticatedMaxRequestsPerMinute; } catch { return DEFAULT_MAX_REQUESTS; } } /** * Singleton rate limiter for authenticated /v1/* requests (per-client-IP). * Its budget is seeded from `apiRateLimit.authenticatedMaxRequestsPerMinute` * at construction and updated in place on config reload via * `refreshAuthenticatedApiRateLimit()`, keeping config-loader work off the * per-request path. */ export const apiRateLimiter = new TokenRateLimiter(); /** * Re-read the authenticated remote budget from workspace configuration and * apply it to the live limiter. Invoked by the config watcher after a * `config.json` change so the new budget takes effect without a restart. */ export function refreshAuthenticatedApiRateLimit(): void { apiRateLimiter.setMaxRequests(resolveAuthenticatedApiMaxRequests()); } /** * Singleton rate limiter for authenticated requests from loopback clients. * Separate instance (not just a higher cap) so local and remote traffic * never share or evict each other's buckets. */ export const loopbackApiRateLimiter = new TokenRateLimiter( LOOPBACK_MAX_REQUESTS, ); /** Singleton rate limiter for unauthenticated requests (per-IP, lower limits). */ export const ipRateLimiter = new TokenRateLimiter( DEFAULT_IP_MAX_REQUESTS, DEFAULT_IP_WINDOW_MS, MAX_TRACKED_IPS, ); /** * Pick the rate limiter for an authenticated request: loopback client IPs * get the higher local budget, everything else gets the standard one. */ export function selectAuthenticatedRateLimiter( clientIp: string, ): TokenRateLimiter { return isLoopbackAddress(clientIp) ? loopbackApiRateLimiter : apiRateLimiter; } /** * `/v1/` endpoints that bypass the per-minute request rate limiter. * * - Liveness/readiness (`health`, `healthz`, `readyz`): cheap probes that * must always answer. The desktop app polls `/v1/health` every few seconds * and the reachability probe hits `/v1/healthz`; a 429 makes the client * treat the assistant as unreachable and reconnect harder. * - SSE stream (`events`): a single long-lived streaming connection, not a * burst of discrete requests. Metering each (re)connect against the * per-minute budget is the wrong model — and 429-ing the stream drops it, * which drives a client reconnect + full re-bootstrap loop that generates * far more load than the limiter saves. The events route still enforces * auth downstream (an unauthenticated stream request is rejected there), * and daemon memory is bounded by SSE backpressure shedding and subscriber * caps rather than by this request-count limiter. * * The argument is the `/v1/`-stripped, trailing-slash-normalized endpoint * segment (e.g. `events`, `health`) as computed by the HTTP server. */ const RATE_LIMIT_EXEMPT_ENDPOINTS = new Set([ "events", "health", "healthz", "readyz", ]); export function isRateLimitExemptEndpoint(endpoint: string): boolean { return RATE_LIMIT_EXEMPT_ENDPOINTS.has(endpoint); } /** * Extract the client IP from a request. Only trusts proxy headers * (X-Forwarded-For, X-Real-IP) when the peer IP is loopback or private, * meaning the request arrived via the gateway. Direct connections from * external clients use the peer IP, preventing header spoofing. */ export function extractClientIp( req: Request, server: { requestIP(req: Request): { address: string } | null }, ): string { const peerIp = server.requestIP(req)?.address ?? "0.0.0.0"; if (isPrivateAddress(peerIp)) { const forwarded = req.headers.get("x-forwarded-for"); if (forwarded) { const first = forwarded.split(",")[0].trim(); if (first) { return first; } } const realIp = req.headers.get("x-real-ip"); if (realIp) { return realIp.trim(); } } return peerIp; }