CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
rate-limit.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 45e31d0 | 1 | /** |
| 2 | * In-memory rate limiter middleware. | |
| 3 | * | |
| 4 | * Provides per-IP rate limiting with sliding window counters. | |
| 5 | * For production, replace with Redis-based implementation. | |
| 6 | */ | |
| 7 | ||
| 8 | import { createMiddleware } from "hono/factory"; | |
| 9 | ||
| 10 | interface RateLimitEntry { | |
| 11 | count: number; | |
| 12 | resetAt: number; | |
| 13 | } | |
| 14 | ||
| 15 | const store = new Map<string, RateLimitEntry>(); | |
| 16 | ||
| 17 | // Cleanup stale entries every 60 seconds | |
| ea52715 | 18 | const _cleanupInterval = setInterval(() => { |
| 45e31d0 | 19 | const now = Date.now(); |
| 20 | for (const [key, entry] of store) { | |
| 21 | if (entry.resetAt < now) { | |
| 22 | store.delete(key); | |
| 23 | } | |
| 24 | } | |
| 25 | }, 60_000); | |
| 26 | ||
| 826eccf | 27 | interface RateLimitOptions { |
| 28 | /** | |
| 29 | * Endpoints that should never count against the bucket. Compared against | |
| 30 | * c.req.path with .startsWith(). Used for high-frequency dashboard | |
| 31 | * plumbing (e.g. `/api/version` is polled every 15s by the layout) so | |
| 32 | * normal navigation doesn't exhaust an admin's `/api/*` budget. | |
| 33 | */ | |
| 34 | skipPaths?: string[]; | |
| 35 | /** | |
| 36 | * Multiplier applied when c.get("user") is set by an upstream softAuth. | |
| 37 | * 1 = anonymous limit. Default 4 so an authed user gets 4x the bucket | |
| 38 | * (admins doing admin work shouldn't hit /api/* limits during normal | |
| 39 | * navigation; anonymous traffic still gets the strict cap). | |
| 40 | */ | |
| 41 | authedMultiplier?: number; | |
| 42 | } | |
| 43 | ||
| 45e31d0 | 44 | /** |
| 45 | * Create a rate limiter middleware. | |
| 826eccf | 46 | * @param maxRequests Maximum requests per window for ANONYMOUS callers |
| 45e31d0 | 47 | * @param windowMs Window duration in milliseconds |
| 48 | * @param keyPrefix Prefix for the rate limit key (allows different limits per route group) | |
| 826eccf | 49 | * @param opts Optional skip-paths and authed multiplier. |
| 45e31d0 | 50 | */ |
| 51 | export function rateLimit( | |
| 52 | maxRequests: number, | |
| 53 | windowMs: number, | |
| 826eccf | 54 | keyPrefix = "global", |
| 55 | opts: RateLimitOptions = {} | |
| 45e31d0 | 56 | ) { |
| 826eccf | 57 | const skipPaths = opts.skipPaths || []; |
| 58 | const authedMultiplier = opts.authedMultiplier ?? 4; | |
| 45e31d0 | 59 | return createMiddleware(async (c, next) => { |
| 699e5c7 | 60 | // In test env, expose informational rate-limit headers but do not actually |
| 61 | // enforce limits — the shared in-memory store leaks across test files and | |
| 62 | // would push requests into 429 once accumulated. | |
| 4127ecf | 63 | // |
| 64 | // Belt-and-braces: refuse to disable enforcement when NODE_ENV=production | |
| 65 | // even if BUN_ENV/NODE_ENV are also somehow set to "test". A misconfigured | |
| 66 | // production container with a leaked test env var must not silently drop | |
| 67 | // rate limiting. | |
| 68 | const isTestEnv = | |
| 69 | process.env.NODE_ENV !== "production" && | |
| 70 | (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test"); | |
| 71 | if (isTestEnv) { | |
| 699e5c7 | 72 | c.header("X-RateLimit-Limit", String(maxRequests)); |
| 73 | c.header("X-RateLimit-Remaining", String(maxRequests)); | |
| 74 | c.header("X-RateLimit-Reset", String(Math.ceil((Date.now() + windowMs) / 1000))); | |
| 75 | return next(); | |
| 76 | } | |
| 77 | ||
| 826eccf | 78 | // Skip-path exemption — applied to dashboard plumbing endpoints that the |
| 79 | // layout polls on a fixed cadence and that we don't want consuming an | |
| 80 | // authenticated user's per-IP budget. | |
| 81 | const path = c.req.path; | |
| 82 | for (const prefix of skipPaths) { | |
| 83 | if (path === prefix || path.startsWith(prefix + "/") || path.startsWith(prefix + "?")) { | |
| 84 | return next(); | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 45e31d0 | 88 | const ip = |
| 89 | c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || | |
| 90 | c.req.header("x-real-ip") || | |
| 91 | "unknown"; | |
| 92 | ||
| 826eccf | 93 | // Effective cap: authed users get a multiplier so a logged-in admin |
| 94 | // clicking around doesn't share the anonymous limit. We DON'T key the | |
| 95 | // bucket per-user — keeping it per-IP is the strongest defence against | |
| 96 | // a stolen-session bot — but a logged-in session signals "human at the | |
| 97 | // keyboard", so we lift the ceiling. | |
| 98 | const user = c.get("user" as never) as { id?: string } | null | undefined; | |
| 99 | const effectiveMax = user ? maxRequests * authedMultiplier : maxRequests; | |
| 100 | ||
| 45e31d0 | 101 | const key = `${keyPrefix}:${ip}`; |
| 102 | const now = Date.now(); | |
| 103 | let entry = store.get(key); | |
| 104 | ||
| 105 | if (!entry || entry.resetAt < now) { | |
| 106 | entry = { count: 0, resetAt: now + windowMs }; | |
| 107 | store.set(key, entry); | |
| 108 | } | |
| 109 | ||
| 110 | entry.count++; | |
| 111 | ||
| 826eccf | 112 | // Set rate limit headers (report the effective cap so clients see the |
| 113 | // bucket they're actually subject to). | |
| 114 | c.header("X-RateLimit-Limit", String(effectiveMax)); | |
| 115 | c.header("X-RateLimit-Remaining", String(Math.max(0, effectiveMax - entry.count))); | |
| 45e31d0 | 116 | c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000))); |
| 117 | ||
| 826eccf | 118 | if (entry.count > effectiveMax) { |
| 45e31d0 | 119 | c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000))); |
| 120 | return c.json( | |
| 121 | { | |
| 122 | error: "Rate limit exceeded", | |
| 123 | retryAfter: Math.ceil((entry.resetAt - now) / 1000), | |
| 124 | }, | |
| 125 | 429 | |
| 126 | ); | |
| 127 | } | |
| 128 | ||
| 129 | return next(); | |
| 130 | }); | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Pre-configured rate limiters for different route groups. | |
| 135 | */ | |
| 3e8f8e8 | 136 | export function clearRateLimitStore() { |
| 137 | store.clear(); | |
| 138 | } | |
| 139 | ||
| 45e31d0 | 140 | export const apiRateLimit = rateLimit(100, 60_000, "api"); // 100 req/min |
| 141 | export const authRateLimit = rateLimit(10, 60_000, "auth"); // 10 req/min (login/register) | |
| a41e675 | 142 | // 300/min for git Smart-HTTP. A clone of a busy repo fans out into many |
| 143 | // info/refs + upload-pack requests; 60 was way too tight when the same IP | |
| 144 | // (a developer's laptop) was clicking around the UI in another tab. | |
| 145 | export const gitRateLimit = rateLimit(300, 60_000, "git"); | |
| 45e31d0 | 146 | export const searchRateLimit = rateLimit(30, 60_000, "search"); // 30 req/min |