rate-limit.ts169 lines · main
| 1 | import { RateLimitedError } from '@briven/shared'; |
| 2 | import type { MiddlewareHandler } from 'hono'; |
| 3 | |
| 4 | import { env } from '../env.js'; |
| 5 | import { getRedis } from '../lib/redis.js'; |
| 6 | import { |
| 7 | RATE_LIMIT_WINDOW_MS, |
| 8 | resolveProjectRateLimit, |
| 9 | type RateLimitScope, |
| 10 | } from '../services/tiers.js'; |
| 11 | |
| 12 | /** |
| 13 | * Sliding-window rate limiter backed by Redis. Fails open (allows the |
| 14 | * request) if Redis is unavailable — preferable for Phase 3 dogfood to |
| 15 | * hard-failing every request on a transient outage. |
| 16 | * |
| 17 | * Key layout: `rl:<scope>:<subject>:<bucket>` where bucket is |
| 18 | * `floor(now / windowMs)`. An adjacent bucket lookup gives us a sliding |
| 19 | * window by weighting the prior bucket's count by how far into the |
| 20 | * current window we are. |
| 21 | */ |
| 22 | export interface RateLimitOptions { |
| 23 | scope: string; // e.g. 'auth', 'invoke', 'deploy' |
| 24 | /** |
| 25 | * Max requests per window. Pass a number for a static cap, or a function |
| 26 | * to compute the cap per-request — used by tier-aware limits where the |
| 27 | * cap depends on the project's subscription tier resolved at request |
| 28 | * time. Returning `null` from the function skips the limit (e.g. when |
| 29 | * the project isn't found and we'd rather pass through than DDoS-amplify |
| 30 | * the lookup). |
| 31 | */ |
| 32 | limit: |
| 33 | | number |
| 34 | | ((c: Parameters<MiddlewareHandler>[0]) => Promise<number | null> | number | null); |
| 35 | windowMs: number; // window size in ms |
| 36 | /** Returns the identifier to rate-limit on (ip/project/user). */ |
| 37 | key: (c: Parameters<MiddlewareHandler>[0]) => string | null; |
| 38 | } |
| 39 | |
| 40 | export function rateLimit(options: RateLimitOptions): MiddlewareHandler { |
| 41 | return async (c, next) => { |
| 42 | // why: outside dev, requests must arrive via the edge proxy, which |
| 43 | // stamps the real client IP. Historically that was Cloudflare's |
| 44 | // `cf-connecting-ip`; this deployment now runs its own DNS straight to |
| 45 | // Traefik, which sets the standard `x-forwarded-for` instead. Accept |
| 46 | // either — a request carrying NEITHER is hitting the origin directly |
| 47 | // (bypassing the proxy to dodge IP-based limits), so reject it before |
| 48 | // any work. Project-keyed limits don't depend on this header; IP-keyed |
| 49 | // ones fall back to x-forwarded-for's first hop via `ipKey`. |
| 50 | if (env.BRIVEN_ENV !== 'development') { |
| 51 | const cf = c.req.raw.headers.get('cf-connecting-ip'); |
| 52 | const fwd = c.req.raw.headers.get('x-forwarded-for'); |
| 53 | if ((!cf || !cf.trim()) && (!fwd || !fwd.trim())) { |
| 54 | return c.json({ code: 'origin_direct_rejected' }, 403); |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | const subject = options.key(c); |
| 59 | const redis = getRedis(); |
| 60 | if (!subject || !redis) { |
| 61 | await next(); |
| 62 | return; |
| 63 | } |
| 64 | |
| 65 | // Resolve the dynamic cap. A null return means "skip rate limiting" |
| 66 | // (e.g. project not found — pass through rather than amplify the |
| 67 | // lookup into a DoS vector). |
| 68 | const limit = |
| 69 | typeof options.limit === 'function' ? await options.limit(c) : options.limit; |
| 70 | if (limit === null) { |
| 71 | await next(); |
| 72 | return; |
| 73 | } |
| 74 | |
| 75 | const now = Date.now(); |
| 76 | const bucket = Math.floor(now / options.windowMs); |
| 77 | const currentKey = `rl:${options.scope}:${subject}:${bucket}`; |
| 78 | const prevKey = `rl:${options.scope}:${subject}:${bucket - 1}`; |
| 79 | |
| 80 | try { |
| 81 | const [currentRaw, prevRaw] = await Promise.all([redis.incr(currentKey), redis.get(prevKey)]); |
| 82 | if (currentRaw === 1) { |
| 83 | // Expire after 2 windows so the prev lookup still works. |
| 84 | await redis.pexpire(currentKey, options.windowMs * 2); |
| 85 | } |
| 86 | |
| 87 | const prev = Number(prevRaw) || 0; |
| 88 | const progress = (now % options.windowMs) / options.windowMs; |
| 89 | const weighted = prev * (1 - progress) + currentRaw; |
| 90 | |
| 91 | if (weighted > limit) { |
| 92 | const retryAfterSec = Math.ceil((options.windowMs - (now % options.windowMs)) / 1000); |
| 93 | c.header('Retry-After', String(retryAfterSec)); |
| 94 | c.header('X-RateLimit-Limit', String(limit)); |
| 95 | c.header('X-RateLimit-Remaining', '0'); |
| 96 | throw new RateLimitedError(retryAfterSec); |
| 97 | } |
| 98 | |
| 99 | c.header('X-RateLimit-Limit', String(limit)); |
| 100 | c.header('X-RateLimit-Remaining', String(Math.max(0, limit - Math.ceil(weighted)))); |
| 101 | } catch (err) { |
| 102 | if (err instanceof RateLimitedError) throw err; |
| 103 | // Redis down → fail open (logged in lib/redis.ts already). |
| 104 | } |
| 105 | |
| 106 | await next(); |
| 107 | return; |
| 108 | }; |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Resolve the client IP for rate-limit keying. Prefers `cf-connecting-ip` |
| 113 | * (set by Cloudflare when fronted by it; not spoofable by clients), then |
| 114 | * falls back to `x-forwarded-for`'s first hop — which is what Traefik sets |
| 115 | * in the current self-hosted (no-Cloudflare) deployment, and what local |
| 116 | * tunnels / docker proxies set in development. Returns null when neither |
| 117 | * is present (the rateLimit middleware then skips IP keying rather than |
| 118 | * bucketing every anonymous request together). |
| 119 | */ |
| 120 | export function ipKey(c: Parameters<MiddlewareHandler>[0]): string | null { |
| 121 | const cf = c.req.raw.headers.get('cf-connecting-ip'); |
| 122 | if (cf) { |
| 123 | const trimmed = cf.trim(); |
| 124 | if (trimmed.length > 0) return trimmed; |
| 125 | } |
| 126 | const fwd = c.req.raw.headers.get('x-forwarded-for'); |
| 127 | if (!fwd) return null; |
| 128 | return fwd.split(',')[0]!.trim(); |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Tier-aware rate limit keyed by `:id` (project) for a given scope. The |
| 133 | * cap is resolved per-request from the project's denormalised tier, with |
| 134 | * `null` (skip rate limiting) returned when the project doesn't exist — |
| 135 | * the auth middleware downstream returns 404 either way, but skipping |
| 136 | * here avoids amplifying an unknown-project lookup into a DoS vector. |
| 137 | */ |
| 138 | export function projectRateLimit(scope: RateLimitScope): MiddlewareHandler { |
| 139 | return rateLimit({ |
| 140 | scope, |
| 141 | limit: (c) => { |
| 142 | const id = c.req.param('id'); |
| 143 | return id ? resolveProjectRateLimit(id, scope) : null; |
| 144 | }, |
| 145 | windowMs: RATE_LIMIT_WINDOW_MS, |
| 146 | key: (c) => c.req.param('id') ?? null, |
| 147 | }); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Flat-cap rate limit keyed by the authenticated user id. Used for routes |
| 152 | * that aren't project-scoped (billing checkout/portal, org member |
| 153 | * mutations) — there's no project tier to read, so a single fixed cap |
| 154 | * applies per user. Unauthenticated requests skip the limit; downstream |
| 155 | * auth middleware returns 401 either way. |
| 156 | * |
| 157 | * Phase 3 §3.7: covers the mutate routes that fell outside `projectRateLimit`. |
| 158 | */ |
| 159 | export function userRateLimit(scope: string, cap: number): MiddlewareHandler { |
| 160 | return rateLimit({ |
| 161 | scope, |
| 162 | limit: cap, |
| 163 | windowMs: RATE_LIMIT_WINDOW_MS, |
| 164 | key: (c) => { |
| 165 | const user = c.get('user') as { id?: string } | undefined; |
| 166 | return user?.id ?? null; |
| 167 | }, |
| 168 | }); |
| 169 | } |