fix(auth): rate limit Better Auth routes per client
This commit is contained in:
@@ -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<Response> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Response> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user