rate-limit.ts169 lines · main
1import { RateLimitedError } from '@briven/shared';
2import type { MiddlewareHandler } from 'hono';
3
4import { env } from '../env.js';
5import { getRedis } from '../lib/redis.js';
6import {
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 */
22export 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
40export 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 */
120export 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 */
138export 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 */
159export 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}