diff --git a/cloudflare/src/api_controls.ts b/cloudflare/src/api_controls.ts index 5a55512..16df159 100644 --- a/cloudflare/src/api_controls.ts +++ b/cloudflare/src/api_controls.ts @@ -6,6 +6,7 @@ import { authenticatedRateLimitKey, readAuthContext, } from "./auth.js"; +import { requiresEmailDelivery } from "./better_auth.js"; import { jsonResponse } from "./responses.js"; const RATE_LIMIT_WINDOW_SECONDS = 60; @@ -60,6 +61,43 @@ export async function withPublicApiControls( } } +/// Better Auth routes are unauthenticated, so they limit per client +/// address. Email-delivery routes consume the tight email limiter; every +/// other auth route shares the standard limiter. +export async function withAuthRouteControls( + request: Request, + env: Env, + handler: ApiHandler, +): Promise { + const emailDelivery = requiresEmailDelivery(request); + const route = emailDelivery ? "auth.email" : "auth.route"; + const allowedMethods = ["GET", "POST"]; + if (!allowedMethods.includes(request.method)) { + const response = methodNotAllowedResponse(allowedMethods); + recordApiAuditEvent(request, env, route, response, "method_not_allowed"); + return response; + } + + const limiter = emailDelivery ? env.ELY_AUTH_EMAIL_RATE_LIMITER : env.ELY_RATE_LIMITER; + const limit = await limiter.limit({ + key: clientRateLimitKey(env.ELY_ENVIRONMENT, route, request), + }); + if (!limit.success) { + const response = rateLimitedResponse(); + recordApiAuditEvent(request, env, route, response, "rate_limited"); + return response; + } + + try { + const response = await handler(); + recordApiAuditEvent(request, env, route, response, "handled"); + return response; + } catch (error) { + recordApiAuditEvent(request, env, route, internalErrorResponse(), "exception"); + throw error; + } +} + export async function withAuthenticatedApiControls( request: Request, env: Env, @@ -148,6 +186,11 @@ function rateLimitKey(environment: string, route: string): string { return `${environment}:${route}`; } +function clientRateLimitKey(environment: string, route: string, request: Request): string { + const client = request.headers.get("cf-connecting-ip") ?? "unknown"; + return `${environment}:${route}:${client}`; +} + function recordApiAuditEvent( request: Request, env: Env, diff --git a/cloudflare/src/better_auth.ts b/cloudflare/src/better_auth.ts index 71545f0..6c1cddd 100644 --- a/cloudflare/src/better_auth.ts +++ b/cloudflare/src/better_auth.ts @@ -40,7 +40,7 @@ export async function handleBetterAuthRoute(request: Request, env: Env): Promise } } -function requiresEmailDelivery(request: Request): boolean { +export function requiresEmailDelivery(request: Request): boolean { if (request.method !== "POST") { return false; } diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index c563017..38619d3 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -93,6 +93,7 @@ export interface Env { ELY_KV: ElyKvNamespace; ELY_STORAGE: ElyR2Bucket; ELY_RATE_LIMITER: ElyRateLimit; + ELY_AUTH_EMAIL_RATE_LIMITER: ElyRateLimit; ELY_API_AUDIT: ElyAnalyticsDataset; ELY_DIAGNOSTICS: ElyAnalyticsDataset; ELY_ENVIRONMENT: string; diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 3a3cbb8..a257529 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -4,6 +4,7 @@ import { deleteAuthenticatedSession, } from "./auth.js"; import { + withAuthRouteControls, withAuthenticatedApiControls, withPublicApiControls, } from "./api_controls.js"; @@ -61,7 +62,7 @@ export default { export async function handleRequest(request: Request, env: Env): Promise { const url = new URL(request.url); if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) { - return handleBetterAuthRoute(request, env); + return withAuthRouteControls(request, env, () => handleBetterAuthRoute(request, env)); } if (url.pathname === "/api/session/logout") { return withAuthenticatedApiControls( diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 407d74f..6ae975b 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -383,6 +383,11 @@ function testEnv(options: TestEnvOptions = {}): Env { return Promise.resolve({ success: options.rateLimitSuccess ?? true }); }, }, + ELY_AUTH_EMAIL_RATE_LIMITER: { + limit(): Promise<{ success: boolean }> { + return Promise.resolve({ success: true }); + }, + }, ELY_API_AUDIT: { writeDataPoint(event?: ElyAnalyticsDataPoint): void { if (event !== undefined) { diff --git a/cloudflare/tests/auth_route_controls.test.ts b/cloudflare/tests/auth_route_controls.test.ts new file mode 100644 index 0000000..54556b1 --- /dev/null +++ b/cloudflare/tests/auth_route_controls.test.ts @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { ElyAnalyticsDataPoint, ElyEmailMessageBuilder, Env } from "../src/bindings.js"; +import { handleRequest } from "../src/index.js"; + +const CLIENT_IP = "203.0.113.9"; + +describe("auth route controls", () => { + it("rate limits OTP email delivery before sending mail", async () => { + const state = testState({ emailLimitSuccess: false }); + const response = await handleRequest(otpRequest(), testEnv(state)); + + assert.equal(response.status, 429); + assert.equal(response.headers.get("retry-after"), "60"); + assert.deepEqual(await response.json(), { error: "rate_limited" }); + assert.deepEqual(state.emailLimitKeys, [`local:auth.email:${CLIENT_IP}`]); + assert.deepEqual(state.generalLimitKeys, []); + assert.deepEqual(state.sentEmails, []); + assert.deepEqual(state.auditEvents[0]?.blobs?.slice(0, 4), [ + "auth.email", + "POST", + "/api/auth/email-otp/send-verification-otp", + "rate_limited", + ]); + }); + + it("rate limits general auth traffic with its own key", async () => { + const state = testState({ generalLimitSuccess: false }); + const response = await handleRequest(sessionRequest(), testEnv(state)); + + assert.equal(response.status, 429); + assert.deepEqual(state.generalLimitKeys, [`local:auth.route:${CLIENT_IP}`]); + assert.deepEqual(state.emailLimitKeys, []); + assert.deepEqual(state.auditEvents[0]?.blobs?.slice(0, 4), [ + "auth.route", + "GET", + "/api/auth/get-session", + "rate_limited", + ]); + }); + + it("delivers OTP mail once the email limiter passes", async () => { + const state = testState({}); + const response = await handleRequest(otpRequest(), testEnv(state)); + + assert.equal(response.status, 200); + assert.deepEqual(state.emailLimitKeys, [`local:auth.email:${CLIENT_IP}`]); + assert.deepEqual(state.generalLimitKeys, []); + assert.equal(state.sentEmails.length, 1); + assert.deepEqual(state.auditEvents[0]?.blobs?.slice(0, 4), [ + "auth.email", + "POST", + "/api/auth/email-otp/send-verification-otp", + "handled", + ]); + }); + + it("rejects unsupported auth methods before rate limiting", async () => { + const state = testState({}); + const response = await handleRequest( + new Request("https://elydora.test/api/auth/get-session", { method: "DELETE" }), + testEnv(state), + ); + + assert.equal(response.status, 405); + assert.deepEqual(state.generalLimitKeys, []); + assert.deepEqual(state.emailLimitKeys, []); + }); +}); + +function otpRequest(): Request { + return new Request("https://elydora.test/api/auth/email-otp/send-verification-otp", { + method: "POST", + headers: { + "content-type": "application/json", + "cf-connecting-ip": CLIENT_IP, + }, + body: JSON.stringify({ email: "user@example.com", type: "sign-in" }), + }); +} + +function sessionRequest(): Request { + return new Request("https://elydora.test/api/auth/get-session", { + headers: { "cf-connecting-ip": CLIENT_IP }, + }); +} + +interface TestState { + emailLimitSuccess: boolean; + generalLimitSuccess: boolean; + emailLimitKeys: string[]; + generalLimitKeys: string[]; + sentEmails: ElyEmailMessageBuilder[]; + auditEvents: ElyAnalyticsDataPoint[]; +} + +function testState(overrides: Partial): TestState { + return { + emailLimitSuccess: true, + generalLimitSuccess: true, + emailLimitKeys: [], + generalLimitKeys: [], + sentEmails: [], + auditEvents: [], + ...overrides, + }; +} + +function testEnv(state: TestState): Env { + return { + ELY_ENVIRONMENT: "local", + ELY_AUTH_BASE_URL: "https://elydora.test", + ELY_AUTH_SECRET: "8b7f1d2b0c9a4e3f91d6c0a45e72b8f3", + ELY_DB: testD1Database(), + ELY_KV: { + get() { + return Promise.resolve(null); + }, + put() { + return Promise.resolve(); + }, + delete() { + return Promise.resolve(); + }, + }, + ELY_STORAGE: { + get() { + return Promise.resolve(null); + }, + put() { + return Promise.resolve({ + arrayBuffer() { + return Promise.resolve(new ArrayBuffer(0)); + }, + }); + }, + delete() { + return Promise.resolve(); + }, + }, + ELY_RATE_LIMITER: { + limit(options: { key: string }) { + state.generalLimitKeys.push(options.key); + return Promise.resolve({ success: state.generalLimitSuccess }); + }, + }, + ELY_AUTH_EMAIL_RATE_LIMITER: { + limit(options: { key: string }) { + state.emailLimitKeys.push(options.key); + return Promise.resolve({ success: state.emailLimitSuccess }); + }, + }, + ELY_API_AUDIT: { + writeDataPoint(event?: ElyAnalyticsDataPoint): void { + if (event !== undefined) { + state.auditEvents.push(event); + } + }, + }, + ELY_DIAGNOSTICS: { + writeDataPoint(): void {}, + }, + SEND_EMAIL: { + send(message: ElyEmailMessageBuilder) { + state.sentEmails.push(message); + return Promise.resolve({ messageId: "test-message-id" }); + }, + }, + }; +} + +function testD1Database(): Env["ELY_DB"] { + return { + prepare() { + return { + bind() { + return this; + }, + first() { + return Promise.resolve(null); + }, + all() { + return Promise.resolve({ results: [], meta: { changes: 1, last_row_id: 1 } }); + }, + run() { + return Promise.resolve({ results: [], meta: { changes: 1, last_row_id: 1 } }); + }, + raw() { + return Promise.resolve([]); + }, + }; + }, + batch() { + return Promise.resolve([]); + }, + exec() { + return Promise.resolve({}); + }, + }; +} diff --git a/cloudflare/tests/better_auth_email_otp.test.ts b/cloudflare/tests/better_auth_email_otp.test.ts index 8fef5e5..b160fc8 100644 --- a/cloudflare/tests/better_auth_email_otp.test.ts +++ b/cloudflare/tests/better_auth_email_otp.test.ts @@ -74,6 +74,11 @@ function testEnv(sentEmails: ElyEmailMessageBuilder[] | null): Env { return Promise.resolve({ success: true }); }, }, + ELY_AUTH_EMAIL_RATE_LIMITER: { + limit() { + return Promise.resolve({ success: true }); + }, + }, ELY_API_AUDIT: { writeDataPoint(): void {}, }, diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts index 98f7638..84abc29 100644 --- a/cloudflare/tests/devices_test_support.ts +++ b/cloudflare/tests/devices_test_support.ts @@ -139,6 +139,11 @@ export function testEnv(options: TestEnvOptions): Env { return Promise.resolve({ success: true }); }, }, + ELY_AUTH_EMAIL_RATE_LIMITER: { + limit(): Promise<{ success: boolean }> { + return Promise.resolve({ success: true }); + }, + }, ELY_API_AUDIT: { writeDataPoint(event?: ElyAnalyticsDataPoint): void { if (event !== undefined) { diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index 3570650..9f18afe 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -374,6 +374,11 @@ function testEnv( return Promise.resolve({ success: true }); }, }, + ELY_AUTH_EMAIL_RATE_LIMITER: { + limit(): Promise<{ success: boolean }> { + return Promise.resolve({ success: true }); + }, + }, ELY_API_AUDIT: { writeDataPoint(): void {}, }, diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index 3baa098..f688578 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -43,3 +43,11 @@ namespace_id = "1001" [ratelimits.simple] limit = 600 period = 60 + +[[ratelimits]] +name = "ELY_AUTH_EMAIL_RATE_LIMITER" +namespace_id = "1002" + + [ratelimits.simple] + limit = 5 + period = 60