UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

387 lines (386 loc) • 11.1 kB
import { wildcardMatch } from "../../utils/wildcard.mjs"; import { safeJSONParse } from "@better-auth/core/utils/json"; import { createRateLimitKey, getIp } from "@better-auth/core/utils/ip"; import { normalizePathname } from "@better-auth/core/utils/url"; //#region src/api/rate-limiter/index.ts const memory = /* @__PURE__ */ new Map(); const MEMORY_STORE_MAX_ENTRIES = 1e5; function pruneMemoryStore() { const now = Date.now(); for (const [key, entry] of memory) if (now >= entry.expiresAt) memory.delete(key); if (memory.size <= MEMORY_STORE_MAX_ENTRIES) return; const overflow = memory.size - MEMORY_STORE_MAX_ENTRIES; let removed = 0; for (const key of memory.keys()) { memory.delete(key); if (++removed >= overflow) break; } } /** * Decide an atomic rate-limit step against an in-memory `RateLimit` snapshot * for the rolling `window` (seconds) and `max`. Shared by the memory backend * (read-decide-write is atomic under single-threaded JS) and as the fallback * for storages lacking an atomic primitive. */ function decideConsume(data, rule, now) { const windowInMs = rule.window * 1e3; if (!data) return { next: { key: "", count: 1, lastRequest: now }, update: false, allowed: true, retryAfter: null }; if (now - data.lastRequest > windowInMs) return { next: { ...data, count: 1, lastRequest: now }, update: true, allowed: true, retryAfter: null }; if (data.count >= rule.max) return { next: data, update: true, allowed: false, retryAfter: getRetryAfter(data.lastRequest, rule.window) }; return { next: { ...data, count: data.count + 1, lastRequest: now }, update: true, allowed: true, retryAfter: null }; } function rateLimitResponse(retryAfter) { return new Response(JSON.stringify({ message: "Too many requests. Please try again later." }), { status: 429, statusText: "Too Many Requests", headers: { "X-Retry-After": retryAfter.toString() } }); } function getRetryAfter(lastRequest, window) { const now = Date.now(); const windowInMs = window * 1e3; return Math.ceil((lastRequest + windowInMs - now) / 1e3); } function createDatabaseStorageWrapper(ctx) { const model = "rateLimit"; const db = ctx.adapter; const readRow = async (key) => { const data = (await db.findMany({ model, where: [{ field: "key", value: key }] }))[0]; if (typeof data?.lastRequest === "bigint") data.lastRequest = Number(data.lastRequest); return data; }; const consume = async (key, rule) => { const windowInMs = rule.window * 1e3; const data = await readRow(key); const now = Date.now(); if (!data) try { await db.create({ model, data: { key, count: 1, lastRequest: now } }); return { allowed: true, retryAfter: null }; } catch (error) { if (!await readRow(key)) throw error; return consume(key, rule); } if (now - data.lastRequest > windowInMs) { if (await db.incrementOne({ model, where: [{ field: "key", value: key }, { field: "lastRequest", operator: "lte", value: data.lastRequest }], increment: {}, set: { count: 1, lastRequest: now } })) { deleteExpiredRows(now); return { allowed: true, retryAfter: null }; } return consume(key, rule); } const windowStart = now - windowInMs; if (await db.incrementOne({ model, where: [ { field: "key", value: key }, { field: "lastRequest", operator: "gt", value: windowStart }, { field: "count", operator: "lt", value: rule.max } ], increment: { count: 1 }, set: { lastRequest: now } })) return { allowed: true, retryAfter: null }; const fresh = await readRow(key); if (!fresh) return consume(key, rule); if (now - fresh.lastRequest > windowInMs) return consume(key, rule); return { allowed: false, retryAfter: getRetryAfter(fresh.lastRequest, rule.window) }; }; const deleteExpiredRows = (now) => { const cutoff = now - Math.max(ctx.rateLimit.window, ...getDefaultSpecialRules().map((r) => r.window)) * 1e3; ctx.runInBackground(db.deleteMany({ model, where: [{ field: "lastRequest", operator: "lt", value: cutoff }] }).then(() => void 0).catch((e) => ctx.logger.error("Error pruning rate limit rows", e))); }; return { get: readRow, set: async (key, value, _update) => { try { if (_update) await db.updateMany({ model, where: [{ field: "key", value: key }], update: { count: value.count, lastRequest: value.lastRequest } }); else await db.create({ model, data: { key, count: value.count, lastRequest: value.lastRequest } }); } catch (e) { ctx.logger.error("Error setting rate limit", e); } }, consume }; } function getRateLimitStorage(ctx, rateLimitSettings) { if (ctx.options.rateLimit?.customStorage) return ctx.options.rateLimit.customStorage; const storage = ctx.rateLimit.storage; if (storage === "secondary-storage") { const ttlFor = (window) => window ?? ctx.options.rateLimit?.window ?? 10; return { get: async (key) => { const data = await ctx.options.secondaryStorage?.get(key); return data ? safeJSONParse(data) : null; }, set: async (key, value, _update) => { await ctx.options.secondaryStorage?.set?.(key, JSON.stringify(value), ttlFor(rateLimitSettings.window)); }, consume: ctx.options.secondaryStorage?.increment ? async (key, rule) => { if (await ctx.options.secondaryStorage.increment(key, ttlFor(rule.window)) <= rule.max) return { allowed: true, retryAfter: null }; return { allowed: false, retryAfter: rule.window }; } : void 0 }; } else if (storage === "memory") { const ttlFor = (window) => window ?? ctx.options.rateLimit?.window ?? 10; return { async get(key) { const entry = memory.get(key); if (!entry) return null; if (Date.now() >= entry.expiresAt) { memory.delete(key); return null; } return entry.data; }, async set(key, value, _update) { const expiresAt = Date.now() + ttlFor(rateLimitSettings.window) * 1e3; memory.set(key, { data: value, expiresAt }); }, async consume(key, rule) { pruneMemoryStore(); const now = Date.now(); const entry = memory.get(key); const decision = decideConsume(entry && now < entry.expiresAt ? entry.data : void 0, rule, now); if (decision.allowed) memory.set(key, { data: { ...decision.next, key }, expiresAt: now + ttlFor(rule.window) * 1e3 }); return { allowed: decision.allowed, retryAfter: decision.retryAfter }; } }; } return createDatabaseStorageWrapper(ctx); } let ipWarningLogged = false; const NO_TRUSTED_IP_KEY = "no-trusted-ip"; async function resolveRateLimitConfig(req, ctx) { const basePath = new URL(ctx.baseURL).pathname; const path = normalizePathname(req.url, basePath); let currentWindow = ctx.rateLimit.window; let currentMax = ctx.rateLimit.max; const ip = getIp(req, ctx.options); if (!ip && ctx.options.advanced?.ipAddress?.disableIpTracking) return null; if (!ip && !ipWarningLogged) { ctx.logger.warn("Rate limiting could not determine a client IP and is falling back to a single shared per-path bucket. Ensure your runtime forwards a trusted client IP header, then set `advanced.ipAddress.ipAddressHeaders` or `advanced.ipAddress.trustedProxies` so the address can be resolved."); ipWarningLogged = true; } const key = createRateLimitKey(ip ?? NO_TRUSTED_IP_KEY, path); const specialRule = getDefaultSpecialRules().find((rule) => rule.pathMatcher(path)); if (specialRule) { currentWindow = specialRule.window; currentMax = specialRule.max; } for (const plugin of ctx.options.plugins || []) if (plugin.rateLimit) { const matchedRule = plugin.rateLimit.find((rule) => rule.pathMatcher(path)); if (matchedRule) { currentWindow = matchedRule.window; currentMax = matchedRule.max; break; } } if (ctx.rateLimit.customRules) { const _path = Object.keys(ctx.rateLimit.customRules).find((p) => { if (p.includes("*")) return wildcardMatch(p)(path); return p === path; }); if (_path) { const customRule = ctx.rateLimit.customRules[_path]; const resolved = typeof customRule === "function" ? await customRule(req, { window: currentWindow, max: currentMax }) : customRule; if (resolved) { currentWindow = resolved.window; currentMax = resolved.max; } if (resolved === false) return null; } } return { key, currentWindow, currentMax }; } let legacyFallbackWarningLogged = false; /** * Decides the rate limit for the request in a single atomic step. The whole * check-and-increment happens here in the request phase; there is no separate * response-phase write-back, so concurrent requests cannot all pass a stale * read before any increment lands. */ async function onRequestRateLimit(req, ctx) { if (!ctx.rateLimit.enabled) return; const config = await resolveRateLimitConfig(req, ctx); if (!config) return; const { key, currentWindow, currentMax } = config; const storage = getRateLimitStorage(ctx, { window: currentWindow }); const rule = { window: currentWindow, max: currentMax }; if (storage.consume) { const { allowed, retryAfter } = await storage.consume(key, rule); if (!allowed) return rateLimitResponse(retryAfter ?? currentWindow); return; } return legacyConsume(ctx, storage, key, rule); } /** * Non-atomic check-then-increment for storages that do not implement `consume` * (custom storages, or secondary storages without `increment`). Under * concurrency this is best-effort: simultaneous requests can each pass the * check before either write lands. * * FIXME(rate-limit-consume-required): remove on `next` once `consume` is the * sole required member of the storage contract. */ async function legacyConsume(ctx, storage, key, rule) { if (!legacyFallbackWarningLogged) { ctx.logger.warn("Rate limiting is best-effort: the configured storage has no atomic `consume`, so concurrent requests may bypass the limit. Provide a storage that implements `consume` for strict enforcement."); legacyFallbackWarningLogged = true; } const decision = decideConsume(await storage.get(key), rule, Date.now()); if (!decision.allowed) return rateLimitResponse(decision.retryAfter ?? rule.window); await storage.set(key, { ...decision.next, key }, decision.update); } function getDefaultSpecialRules() { return [{ pathMatcher(path) { return path.startsWith("/sign-in") || path.startsWith("/sign-up") || path.startsWith("/change-password") || path.startsWith("/change-email"); }, window: 10, max: 3 }, { pathMatcher(path) { return path === "/request-password-reset" || path === "/send-verification-email" || path.startsWith("/forget-password") || path === "/email-otp/send-verification-otp" || path === "/email-otp/request-password-reset"; }, window: 60, max: 3 }]; } //#endregion export { onRequestRateLimit };