rateLimiter.ts
| 1 | import config from "../config.ts"; |
| 2 | |
| 3 | interface Bucket { |
| 4 | count: number; |
| 5 | resetAt: number; |
| 6 | } |
| 7 | |
| 8 | export type RateLimitKind = |
| 9 | | "login" |
| 10 | | "passkey" |
| 11 | | "git-auth" |
| 12 | | "comment" |
| 13 | | "reaction" |
| 14 | | "upload" |
| 15 | | "register" |
| 16 | | "repo-create" |
| 17 | | "issue-create" |
| 18 | | "patch-create" |
| 19 | | "label-write" |
| 20 | | "release-write" |
| 21 | | "file-edit"; |
| 22 | |
| 23 | const buckets = new Map<string, Bucket>(); |
| 24 | |
| 25 | function sweep() { |
| 26 | const now = Date.now(); |
| 27 | for (const [key, bucket] of buckets) { |
| 28 | if (now > bucket.resetAt) buckets.delete(key); |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // Periodic sweep so memory doesn't grow unboundedly when traffic is low |
| 33 | // and the request-driven sweep never reaches its threshold. |
| 34 | setInterval(sweep, 60 * 1000).unref(); |
| 35 | |
| 36 | let sweepCounter = 0; |
| 37 | function maybeSweep() { |
| 38 | if (++sweepCounter < 1000) return; |
| 39 | sweepCounter = 0; |
| 40 | sweep(); |
| 41 | } |
| 42 | |
| 43 | export function checkRateLimit( |
| 44 | ip: string | null, |
| 45 | kind: RateLimitKind, |
| 46 | maxRequests: number, |
| 47 | windowMs: number, |
| 48 | ): boolean { |
| 49 | if (config.RATE_LIMIT_DISABLED || !ip) return true; |
| 50 | const key = `${ip}|${kind}`; |
| 51 | const now = Date.now(); |
| 52 | const bucket = buckets.get(key); |
| 53 | if (!bucket || now > bucket.resetAt) { |
| 54 | buckets.set(key, { count: 1, resetAt: now + windowMs }); |
| 55 | maybeSweep(); |
| 56 | return true; |
| 57 | } |
| 58 | if (bucket.count >= maxRequests) return false; |
| 59 | bucket.count++; |
| 60 | return true; |
| 61 | } |
| 62 | |
| 63 | export function getClientIp( |
| 64 | request: Request, |
| 65 | server: Bun.Server<unknown> | null, |
| 66 | ): string | null { |
| 67 | if (config.TRUSTED_PROXY) { |
| 68 | return ( |
| 69 | request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? |
| 70 | null |
| 71 | ); |
| 72 | } |
| 73 | return server?.requestIP(request)?.address ?? null; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Rate-limit a mutating handler. Returns null if the request is allowed, |
| 78 | * or a 429 Response if it isn't. The bucket is keyed on the user id when |
| 79 | * available (so a single attacker can't bypass by rotating source IPs) |
| 80 | * and on the IP when not. |
| 81 | */ |
| 82 | export function rateLimit( |
| 83 | request: Request, |
| 84 | server: Bun.Server<unknown> | null, |
| 85 | userId: number | null, |
| 86 | kind: RateLimitKind, |
| 87 | maxRequests: number, |
| 88 | windowMs: number, |
| 89 | ): Response | null { |
| 90 | const key = userId !== null ? `u${userId}` : getClientIp(request, server); |
| 91 | if (checkRateLimit(key, kind, maxRequests, windowMs)) return null; |
| 92 | return new Response("Too many requests. Please slow down.", { |
| 93 | status: 429, |
| 94 | headers: { "Content-Type": "text/plain; charset=utf-8" }, |
| 95 | }); |
| 96 | } |
| 97 |