import { env } from "cloudflare:workers"; import { getRequestHeader } from "@tanstack/react-start/server"; import { HttpError } from "./errors"; import { assertVerifiedForWrites } from "./verified-writes"; import { first, run } from "./db"; import { ipBucket } from "~/lib/ip"; import type { Viewer } from "~/lib/types"; import { rateLimitDigest, rateLimitShard } from "./rate-limit-shard"; const HTTP_TOO_MANY = 429; const HTTP_UNAVAILABLE = 503; type LimiterName = "AUTH_LIMITER" | "WRITE_LIMITER" | "HEAVY_LIMITER" | "GIT_LIMITER" | "REPORT_LIMITER"; /** * Mirrors wrangler.jsonc `ratelimits` (the binding config is not readable at runtime). `failClosed` limiters * refuse when the RateLimiter object is unreachable; the rest let traffic through so an outage can't take * the forge down (RL-1). */ export const RATE_LIMITS: Record = { AUTH_LIMITER: { limit: 10, periodSeconds: 60, failClosed: true }, WRITE_LIMITER: { limit: 30, periodSeconds: 60, failClosed: false }, HEAVY_LIMITER: { limit: 30, periodSeconds: 60, failClosed: false }, GIT_LIMITER: { limit: 120, periodSeconds: 60, failClosed: false }, REPORT_LIMITER: { limit: 5, periodSeconds: 60, failClosed: true }, }; /** Per-isolate memo of refusals only: a key known to be over budget skips the RPC until its window ends. */ const refusedUntil = new Map(); const REFUSAL_CACHE_MAX = 10_000; function cachedRefusal(digest: string, now: number): boolean { const until = refusedUntil.get(digest); if (until === undefined) return false; if (until > now) return true; refusedUntil.delete(digest); return false; } function rememberRefusal(digest: string, until: number): void { if (refusedUntil.size >= REFUSAL_CACHE_MAX) refusedUntil.clear(); refusedUntil.set(digest, until); } /** Test hook: forget cached refusals. */ export function resetRateLimitCache(): void { refusedUntil.clear(); } type Outcome = "allowed" | "limited" | "unavailable"; /** * The platform binding runs alongside as a cheap check (honored when it refuses, but it is eventually consistent * and was observed never to refuse); the RateLimiter Durable Object is the authoritative counter. */ async function checkRateLimit(limiter: LimiterName, key: string): Promise { const [platform, counted] = await Promise.all([platformAllows(limiter, key), countInObject(limiter, key)]); return platform ? counted : "limited"; } async function platformAllows(limiter: LimiterName, key: string): Promise { try { return (await env[limiter].limit({ key })).success; } catch (error) { console.error("rate limit binding failed", limiter, error); return true; } } async function countInObject(limiter: LimiterName, key: string): Promise { const config = RATE_LIMITS[limiter]; const now = Date.now(); try { const digest = await rateLimitDigest(limiter, key); if (cachedRefusal(digest, now)) return "limited"; const stub = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName(rateLimitShard(digest))); const verdict = await stub.hit(digest, config.limit, config.periodSeconds * 1000); if (verdict.success) return "allowed"; rememberRefusal(digest, verdict.resetAt); return "limited"; } catch (error) { console.error("rate limiter object failed", limiter, error); return config.failClosed ? "unavailable" : "allowed"; } } /** The exact client address from Cloudflare; use for logs and Turnstile, not for rate-limit keys. */ export function rawRequestIp(request?: Request): string { const header = request ? request.headers.get("cf-connecting-ip") : getRequestHeader("cf-connecting-ip"); return header ?? "unknown"; } /** The client address as a rate-limit and quota key: IPv6 is bucketed to its /64. */ export function requestIp(request?: Request): string { return ipBucket(rawRequestIp(request)); } /** * Local e2e runs only: RATE_LIMITS_DISABLED=true turns the limiters off, and only when PUBLIC_URL is a * loopback address, so it can never take effect on a deployed forge. */ function rateLimitsDisabled(): boolean { if (env.RATE_LIMITS_DISABLED !== "true") return false; try { const host = new URL(env.PUBLIC_URL).hostname; return host === "localhost" || host === "127.0.0.1" || host === "[::1]"; } catch { return false; } } /** Throws 429 once the key exceeds its budget, or 503 when a fail-closed limiter can't be consulted. */ export async function enforceRateLimit(limiter: LimiterName, key: string): Promise { if (rateLimitsDisabled()) return; const outcome = await checkRateLimit(limiter, key); if (outcome === "limited") throw new HttpError(HTTP_TOO_MANY, "Rate limit exceeded; slow down and try again shortly"); if (outcome === "unavailable") throw new HttpError(HTTP_UNAVAILABLE, "Rate limiter unavailable; try again shortly"); } /** Non-throwing variant for optional fan-out (e.g. webhooks): false once the key's budget is spent. */ export async function withinRateLimit(limiter: LimiterName, key: string): Promise { if (rateLimitsDisabled()) return true; return (await checkRateLimit(limiter, key)) === "allowed"; } const HOUR_MS = 60 * 60 * 1000; /** Hourly budgets for credential checks, on top of the per-minute AUTH_LIMITER burst limit. */ export const HOURLY = { confirmPassword: 20, twoFactorPerUser: 20, twoFactorPerIp: 60, } as const; /** Counts one hit against `key` in the current clock hour (D1-backed); throws 429 once over `max`. */ export async function enforceHourlyLimit(key: string, max: number, now = Date.now()): Promise { if (rateLimitsDisabled()) return; const window = Math.floor(now / HOUR_MS) * HOUR_MS; const row = await first<{ hits: number }>( `INSERT INTO hourly_limits (key, window_start, hits) VALUES (?, ?, 1) ON CONFLICT (key, window_start) DO UPDATE SET hits = hits + 1 RETURNING hits`, key, window, ); if ((row?.hits ?? 1) > max) throw new HttpError(HTTP_TOO_MANY, "Too many attempts this hour; try again later"); } export async function pruneHourlyLimits(now = Date.now()): Promise { await run("DELETE FROM hourly_limits WHERE window_start < ?", now - 2 * HOUR_MS); } /** Highest page a paginated list accepts; deeper offsets are a full-table scan per request. */ export const MAX_PAGE = 1000; /** Rate-limit key for the current caller: the signed-in user, otherwise the client IP. */ export function callerKey(viewer: Viewer | null, request?: Request): string { return viewer ? `user:${viewer.id}` : `ip:${requestIp(request)}`; } /** Rate-limits a write and refuses it for an unverified account (AUTH-B1); the default for every write path. */ export async function limitWrite(viewer: Viewer): Promise { assertVerifiedForWrites(viewer); await enforceRateLimit("WRITE_LIMITER", `user:${viewer.id}`); } /** * `limitWrite` without the verified-email gate, for account self-service an unverified holder must keep: * profile, emails, password, 2FA, sessions, notifications, follows, token revocation and deletion. */ export async function limitAccountWrite(viewer: Viewer): Promise { await enforceRateLimit("WRITE_LIMITER", `user:${viewer.id}`); } export async function limitHeavy(viewer: Viewer | null, request?: Request): Promise { await enforceRateLimit("HEAVY_LIMITER", callerKey(viewer, request)); } /** Rejects creation once a per-scope count cap is reached, bounding fan-out work and storage. */ export function assertBelowCap(count: number, cap: number, what: string): void { if (count >= cap) throw new HttpError(HTTP_TOO_MANY, `Limit reached: at most ${cap} ${what}`); } export function numberVar(value: string | undefined, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } export const CAPS = { webhooksPerRepo: 20, secretsPerRepo: 100, runnersPerScope: 50, labelsPerRepo: 200, protectionsPerRepo: 50, tokensPerUser: 50, collaboratorsPerRepo: 200, reposPerOwner: 1000, notificationRecipients: 500, activeRunsPerRepo: 20, pendingApprovalsPerRepo: 50, passkeysPerUser: 20, deployTokensPerRepo: 50, reposPerFineGrainedToken: 50, followingPerUser: 2000, threadSubscriptionsPerUser: 5000, environmentsPerRepo: 50, variablesPerScope: 200, schedulesPerRepo: 10, cacheEntriesPerRepo: 1000, releasesPerRepo: 500, /** Partner repositories handled per hidden-ref cleanup attempt (GIT-A12). */ hiddenRefPartnersPerAttempt: 50, /** Repositories whose cleanup advances per cron run (GIT-A12). */ hiddenRefCleanupsPerRun: 20, /** Pull requests closed and forks detached per access-revocation sweep (GIT-A1, GIT-A8). */ revocationSweepRows: 200, /** KV cache keys purged per repository deletion or visibility change (GIT-A13). */ cachePurgeKeys: 1000, } as const; /** Page-load budget for repository browsing; every page costs Artifacts operations. */ export async function limitBrowse(viewer: Viewer | null, request?: Request): Promise { await enforceRateLimit("GIT_LIMITER", `browse:${callerKey(viewer, request)}`); }