Skip to content
File

Blob: src/worker/middleware/rate-limit.ts

typescript123 lines
1import type { MiddlewareHandler } from "hono";
2 
3import type { AppBindings } from "@/worker/hono";
4import { errorContext, type Logger } from "@/worker/logger";
5 
6const WINDOW_SECONDS = 60;
7 
8export interface RateLimitDecision {
9 allowed: boolean;
10 retryAfterSeconds: number;
11}
12 
13// Caller passes the binding directly so different call sites can pick a
14// tier (e.g. `env.RL_AUTH` for tight, `env.RL_API` for loose). Bucket
15// namespacing keeps unrelated callers on the same binding from sharing
16// counters. The leading `log` arg lets us fail closed loudly on a
17// binding throw without threading a separate logger through every
18// callsite.
19export const enforceRateLimit = async (
20 log: Logger,
21 binding: RateLimit,
22 bucket: string,
23 identifier: string,
24): Promise<RateLimitDecision> => {
25 const key = `rl:${bucket}:${identifier}`;
26 try {
27 const { success } = await binding.limit({ key });
28 if (!success) {
29 return { allowed: false, retryAfterSeconds: WINDOW_SECONDS };
30 }
31 return { allowed: true, retryAfterSeconds: 0 };
32 } catch (err) {
33 // Fail closed. A binding throw must not let the request through
34 // un-charged — a hiccup would otherwise silently disable throttling
35 // on the most abuse-prone endpoints (sign-in, invite-accept) since
36 // the throw escapes through the global handler as a 500 without
37 // incrementing any counter. Returning `allowed: false` here forces
38 // the caller's standard 429 response and keeps abuse capped.
39 log.child({ component: "rate-limit" }).error("rate_limit_binding_error", {
40 bucket,
41 ...errorContext(err, "info"),
42 });
43 return { allowed: false, retryAfterSeconds: WINDOW_SECONDS };
44 }
45};
46 
47export const rateLimitResponse = (decision: RateLimitDecision): Response =>
48 new Response(
49 JSON.stringify({
50 error: "rate_limited",
51 retry_after: decision.retryAfterSeconds,
52 // Surface a render-friendly string so consumers (sign-in,
53 // invite-accept) can show a meaningful 429 instead of falling
54 // through to a generic "Sign-in failed." copy when they read
55 // body.message.
56 message: `Too many requests. Try again in ${decision.retryAfterSeconds} seconds.`,
57 }),
58 {
59 status: 429,
60 headers: {
61 "content-type": "application/json",
62 "retry-after": String(decision.retryAfterSeconds),
63 },
64 },
65 );
66 
67// Public, cacheable, or stateless paths that should not consume budget
68// even when called repeatedly.
69const EXEMPT_AUTH_PATHS = new Set(["/api/auth/jwks", "/api/auth/ok", "/api/auth/error"]);
70 
71// Hot reads served by the Better Auth catchall: session reads (called
72// on every page navigation by the SPA) and RP-backend OAuth reads
73// (/oauth2/userinfo, /oauth2/introspect — called per downstream user
74// request by relying parties). Bucketed against RL_API so a multi-tab
75// SPA or busy RP backend does not exhaust the tight RL_AUTH budget that
76// abuse-prone routes share.
77const LOOSE_AUTH_PATHS = new Set([
78 "/api/auth/get-session",
79 "/api/auth/update-session",
80 "/api/auth/list-sessions",
81 "/api/auth/list-accounts",
82 "/api/auth/oauth2/userinfo",
83 "/api/auth/oauth2/introspect",
84]);
85 
86const selectAuthTier = (env: Env, path: string): { binding: RateLimit; bucket: string } | null => {
87 if (EXEMPT_AUTH_PATHS.has(path)) return null;
88 if (LOOSE_AUTH_PATHS.has(path)) return { binding: env.RL_API, bucket: "auth-api" };
89 return { binding: env.RL_AUTH, bucket: "auth-tight" };
90};
91 
92// Path-aware rate limiter mounted on /api/auth/* before Better Auth's
93// catchall. Better Auth's own rate limiter is intentionally disabled
94// (see `rateLimit: { enabled: false }` in makeAuth) so tessera owns
95// enforcement here through Cloudflare's native RateLimit bindings.
96//
97// Mount order: this runs after `BLOCKED_AUTH_PATHS` and
98// `adminRouteAllowlist` so blocked / non-allowlisted paths 404 without
99// burning budget.
100//
101// CF-Connecting-IP missing: skip the limiter. Cloudflare always sets
102// this header on requests reaching the worker through a configured
103// route, so a missing value means the request did not come through
104// Cloudflare's edge (e.g. local dev / tests via miniflare's SELF.fetch).
105// Tessera-owned wrappers (sign-in, invite) still call the fail-closed
106// `remoteIp` for their own enforcement; that boundary is unchanged.
107export const rateLimitAuthSurface: MiddlewareHandler<AppBindings> = async (c, next) => {
108 const tier = selectAuthTier(c.env, c.req.path);
109 if (!tier) return next();
110 const ip = c.req.header("CF-Connecting-IP")?.trim();
111 if (!ip) return next();
112 const decision = await enforceRateLimit(c.var.log, tier.binding, tier.bucket, ip);
113 if (!decision.allowed) {
114 c.var.log.child({ component: "rate-limit-auth" }).warn("auth_surface_rate_limited", {
115 bucket: tier.bucket,
116 path: c.req.path,
117 retryAfterSeconds: decision.retryAfterSeconds,
118 });
119 return rateLimitResponse(decision);
120 }
121 return next();
122};