From 91f21f756f45acfec794b8deeb1e795471b99b15 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:45:46 +0100 Subject: [PATCH 1/2] fix(web): rate-limit and shorten lifetime of login OTP codes --- apps/web/app/api/auth/[...nextauth]/route.ts | 56 ++++++++++++++- apps/web/lib/rate-limit.ts | 76 ++++++++++++++++++++ packages/database/auth/auth-options.ts | 6 +- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/rate-limit.ts diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts index e89058ce413..a97643178ee 100644 --- a/apps/web/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -1,8 +1,62 @@ import { authOptions } from "@cap/database/auth/auth-options"; +import { type NextRequest, NextResponse } from "next/server"; import NextAuth from "next-auth"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; const handler = NextAuth(authOptions()); -export { handler as GET, handler as POST }; +/** + * Per-email rate limit for the email OTP flow. The NextAuth handler itself has + * no rate limiting and the proxy middleware excludes `/api`, so without this an + * attacker can brute-force the 6-digit code (`/callback/email`) or mailbomb an + * address (`/signin/email`). Keyed on the target email so guesses/sends are + * capped per victim regardless of source IP. + * + * Requires the `rl_auth_otp_verify` / `rl_auth_otp_send` rules to be configured + * in the Vercel Firewall; absent that, this fails open (see `isRateLimited`). + */ +async function otpRateLimited(req: NextRequest): Promise { + const path = req.nextUrl.pathname; + const isVerify = path.includes("/callback/email"); + const isSend = path.endsWith("/signin/email"); + if (!isVerify && !isSend) return false; + + let email = req.nextUrl.searchParams.get("email"); + if (!email && isSend) { + // `signIn("email", …)` posts the address as form data. + try { + const form = await req.clone().formData(); + const value = form.get("email"); + email = typeof value === "string" ? value : null; + } catch { + email = null; + } + } + + const ruleId = isVerify + ? RATE_LIMIT_IDS.AUTH_OTP_VERIFY + : RATE_LIMIT_IDS.AUTH_OTP_SEND; + + return isRateLimited(ruleId, { + key: `${ruleId}:${(email ?? "unknown").toLowerCase()}`, + headers: req.headers, + }); +} + +async function guarded( + req: NextRequest, + ctx: RouteContext<"/api/auth/[...nextauth]">, +) { + if (await otpRateLimited(req)) { + return NextResponse.json( + { error: "Too many attempts. Please try again later." }, + { status: 429 }, + ); + } + + return handler(req, ctx); +} + +export { guarded as GET, guarded as POST }; diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts new file mode 100644 index 00000000000..168a800f168 --- /dev/null +++ b/apps/web/lib/rate-limit.ts @@ -0,0 +1,76 @@ +import { checkRateLimit } from "@vercel/firewall"; +import { headers as nextHeaders } from "next/headers"; + +/** + * Best-effort per-key rate limiting backed by the Vercel Firewall. + * + * IMPORTANT: each `ruleId` passed here must also be configured as a Rate Limit + * rule in the Vercel Firewall dashboard (Firewall → Rate Limiting) with a + * `@vercel/firewall` rule condition whose ID matches `ruleId`, plus the desired + * window / limit / action. An ID that has no matching dashboard rule fails + * OPEN (`checkRateLimit` returns `{ rateLimited: false, error: "not-found" }`), + * so this helper never breaks self-hosted deploys that lack the firewall — but + * it also provides no protection until the rule exists. + * + * Mirrors the existing pattern in `actions/collections/password.ts` and + * `actions/send-download-link.ts`: + * - only enforced in production, + * - best-effort (any error → not limited) so a firewall/IP-header outage can + * never take down the underlying feature. + * + * @param ruleId Stable rule id, also configured in the Vercel Firewall. + * @param opts.key Optional bucket key (e.g. per-email / per-user). Defaults to + * the caller IP (the firewall's default behaviour). + * @param opts.headers Optional request headers (required inside Hono handlers + * where `next/headers` is unavailable; defaults to the App + * Router request headers). + * @returns `true` when the request should be rejected. + */ +export async function isRateLimited( + ruleId: string, + opts?: { key?: string; headers?: Headers }, +): Promise { + if (process.env.NODE_ENV !== "production") return false; + + try { + const headersList = opts?.headers ?? (await nextHeaders()); + const request = new Request("https://cap.so/api/rate-limit", { + method: "POST", + headers: headersList, + }); + + const { rateLimited } = await checkRateLimit(ruleId, { + request, + ...(opts?.key ? { rateLimitKey: opts.key } : {}), + }); + + return rateLimited; + } catch (error) { + console.warn(`Rate limit check failed for "${ruleId}":`, error); + return false; + } +} + +/** + * Canonical Vercel Firewall rate-limit rule ids introduced by the security + * hardening pass. Each MUST be created in the Vercel Firewall dashboard for the + * corresponding protection to take effect (see `isRateLimited`). + */ +export const RATE_LIMIT_IDS = { + /** Email OTP verification attempts (brute-force guard). Suggested: 10 / 10m per key (email). */ + AUTH_OTP_VERIFY: "rl_auth_otp_verify", + /** Email OTP / magic-link send (mailbomb + token-reseed guard). Suggested: 5 / 10m per key (email). */ + AUTH_OTP_SEND: "rl_auth_otp_send", + /** Unauthed Loom download/convert (ffmpeg + memory DoS). Suggested: 10 / 1m per IP. */ + LOOM_DOWNLOAD: "rl_loom_download", + /** Unauthed transcript translation (Groq cost). Suggested: 10 / 1m per IP. */ + TRANSLATE_TRANSCRIPT: "rl_translate_transcript", + /** Anonymous support-chat messages (Groq + Supermemory cost). Suggested: 20 / 1m per IP. */ + MESSENGER_MESSAGE: "rl_messenger_message", + /** Unauthed analytics view tracking (Tinybird ingest + notifications). Suggested: 60 / 1m per IP. */ + ANALYTICS_TRACK: "rl_analytics_track", + /** Unauthed guest checkout (Stripe object/cost abuse). Suggested: 10 / 10m per IP. */ + GUEST_CHECKOUT: "rl_guest_checkout", + /** Unauthed desktop log → Discord forwarding (spam). Suggested: 10 / 1m per IP. */ + DESKTOP_LOGS: "rl_desktop_logs", +} as const; diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 1e3a886b2b1..d49a9d967b9 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -97,6 +97,10 @@ export const authOptions = (): NextAuthOptions => { }, }), EmailProvider({ + // Verification codes expire after 15 minutes (NextAuth defaults to + // 24h). Combined with the per-email rate limit on the callback route + // this bounds OTP brute-force to a few guesses per code lifetime. + maxAge: 15 * 60, async generateVerificationToken() { return crypto.randomInt(100000, 1000000).toString(); }, @@ -114,7 +118,7 @@ export const authOptions = (): NextAuthOptions => { ); console.log(`📧 Email: ${identifier}`); console.log(`🔢 Code: ${token}`); - console.log(`⏱ Expires in: 10 minutes`); + console.log(`⏱ Expires in: 15 minutes`); console.log( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", ); From 68c87bbba0ad9566ed131d7085b264208ff4e62a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:19:35 +0100 Subject: [PATCH 2/2] fix(web): refine OTP rate-limit key handling and verify-path match --- apps/web/app/api/auth/[...nextauth]/route.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts index a97643178ee..06eefbdd975 100644 --- a/apps/web/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -11,15 +11,21 @@ const handler = NextAuth(authOptions()); * Per-email rate limit for the email OTP flow. The NextAuth handler itself has * no rate limiting and the proxy middleware excludes `/api`, so without this an * attacker can brute-force the 6-digit code (`/callback/email`) or mailbomb an - * address (`/signin/email`). Keyed on the target email so guesses/sends are - * capped per victim regardless of source IP. + * address (`/signin/email`). + * + * Keying: when the target email is present the bucket is per-email, so guesses + * and sends are capped per victim regardless of source IP. This is a deliberate + * trade-off favouring mailbomb / brute-force protection; the short 15-minute + * code lifetime bounds any victim-targeted bucket exhaustion to that window. If + * the email is absent we fall back to the firewall's default per-IP bucket + * rather than lumping all email-less requests into one shared key. * * Requires the `rl_auth_otp_verify` / `rl_auth_otp_send` rules to be configured * in the Vercel Firewall; absent that, this fails open (see `isRateLimited`). */ async function otpRateLimited(req: NextRequest): Promise { const path = req.nextUrl.pathname; - const isVerify = path.includes("/callback/email"); + const isVerify = path.endsWith("/callback/email"); const isSend = path.endsWith("/signin/email"); if (!isVerify && !isSend) return false; @@ -39,8 +45,10 @@ async function otpRateLimited(req: NextRequest): Promise { ? RATE_LIMIT_IDS.AUTH_OTP_VERIFY : RATE_LIMIT_IDS.AUTH_OTP_SEND; + const normalizedEmail = email?.trim().toLowerCase(); + return isRateLimited(ruleId, { - key: `${ruleId}:${(email ?? "unknown").toLowerCase()}`, + key: normalizedEmail ? `${ruleId}:${normalizedEmail}` : undefined, headers: req.headers, }); }