From e78863631b6b09dd230538781ae6c5a35f0c8326 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 1 Sep 2025 12:33:37 +0200 Subject: [PATCH 01/20] Feature: Implement Sign-Up Rate Limiting --- src/app/api/auth/confirm/route.ts | 13 ++++ src/configs/flags.ts | 4 ++ src/configs/keys.ts | 1 + src/lib/env.ts | 13 ++-- src/server/auth/auth-actions.ts | 18 ++++++ src/server/auth/rate-limiting.ts | 100 ++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 src/server/auth/rate-limiting.ts diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 91a95a26d..7bbb98987 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -1,7 +1,9 @@ +import { ENABLE_SIGN_UP_RATE_LIMITING } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l } from '@/lib/clients/logger/logger' import { createClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' +import { incrementSignUpAttempts } from '@/server/auth/rate-limiting' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' import { serializeError } from 'serialize-error' @@ -135,6 +137,17 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(redirectUrl.toString()) } + // increment counter for successful sign-up confirmations (rate limiting) + if (ENABLE_SIGN_UP_RATE_LIMITING && supabaseType === 'signup') { + const ip = + request.headers.get('x-forwarded-for') || + request.headers.get('cf-connecting-ip') || + request.headers.get('x-real-ip') || + 'unknown' + + await incrementSignUpAttempts(ip) + } + l.info({ key: 'auth_confirm:success', user_id: data?.user?.id, diff --git a/src/configs/flags.ts b/src/configs/flags.ts index d4ad5f956..56270c8da 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -1,3 +1,7 @@ export const ALLOW_SEO_INDEXING = process.env.ALLOW_SEO_INDEXING === '1' export const VERBOSE = process.env.NEXT_PUBLIC_VERBOSE === '1' export const INCLUDE_BILLING = process.env.NEXT_PUBLIC_INCLUDE_BILLING === '1' + +// Feature flag: enable/disable sign-up rate limiting +export const ENABLE_SIGN_UP_RATE_LIMITING = + process.env.ENABLE_SIGN_UP_RATE_LIMITING === '1' diff --git a/src/configs/keys.ts b/src/configs/keys.ts index cac3db66a..90f112b9d 100644 --- a/src/configs/keys.ts +++ b/src/configs/keys.ts @@ -19,4 +19,5 @@ export const KV_KEYS = { TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`, TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`, WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`, + SIGN_UP_RATE_LIMIT: (identifier: string) => `signup_rate_limit:${identifier}`, } diff --git a/src/lib/env.ts b/src/lib/env.ts index 5dde51bf6..38e639060 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,5 +1,7 @@ import { z } from 'zod' +const TruethyOrFalsy = z.enum(['1', '0']) + export const serverSchema = z.object({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), INFRA_API_URL: z.string().url(), @@ -9,6 +11,9 @@ export const serverSchema = z.object({ BILLING_API_URL: z.string().url().optional(), ZEROBOUNCE_API_KEY: z.string().optional(), + ENABLE_SIGN_UP_RATE_LIMITING: TruethyOrFalsy.optional(), + SIGN_UP_LIMIT_PER_WINDOW: z.coerce.number().optional(), + SIGN_UP_WINDOW_HOURS: z.coerce.number().optional(), OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), @@ -41,10 +46,10 @@ export const clientSchema = z.object({ NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1).optional(), - NEXT_PUBLIC_INCLUDE_BILLING: z.string().optional(), - NEXT_PUBLIC_SCAN: z.string().optional(), - NEXT_PUBLIC_MOCK_DATA: z.string().optional(), - NEXT_PUBLIC_VERBOSE: z.string().optional(), + NEXT_PUBLIC_INCLUDE_BILLING: TruethyOrFalsy.optional(), + NEXT_PUBLIC_SCAN: TruethyOrFalsy.optional(), + NEXT_PUBLIC_MOCK_DATA: TruethyOrFalsy.optional(), + NEXT_PUBLIC_VERBOSE: TruethyOrFalsy.optional(), }) export const testEnvSchema = z.object({ diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index c5d1b0725..a4c0ede1f 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -1,5 +1,6 @@ 'use server' +import { ENABLE_SIGN_UP_RATE_LIMITING } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' import { actionClient } from '@/lib/clients/action' @@ -17,6 +18,7 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' +import { isSignUpRateLimited } from './rate-limiting' export const signInWithOAuthAction = actionClient .schema( @@ -93,6 +95,18 @@ export const signUpAction = actionClient } } + const ip = + (await headers()).get('x-forwarded-for') || + (await headers()).get('cf-connecting-ip') || + (await headers()).get('x-real-ip') || + 'unknown' + + if (ENABLE_SIGN_UP_RATE_LIMITING && (await isSignUpRateLimited(ip))) { + return returnServerError( + 'Too many sign-ups for now. Please try again later.' + ) + } + const { error } = await supabase.auth.signUp({ email, password, @@ -112,6 +126,10 @@ export const signUpAction = actionClient return returnServerError(USER_MESSAGES.emailInUse.message) case 'weak_password': return returnServerError(USER_MESSAGES.passwordWeak.message) + case 'email_address_invalid': + return returnServerError( + USER_MESSAGES.signUpEmailValidationInvalid.message + ) default: throw error } diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts new file mode 100644 index 000000000..171f03235 --- /dev/null +++ b/src/server/auth/rate-limiting.ts @@ -0,0 +1,100 @@ +import { KV_KEYS } from '@/configs/keys' +import { kv } from '@/lib/clients/kv' +import { l } from '@/lib/clients/logger/logger' +import { serializeError } from 'serialize-error' + +const SIGN_UP_LIMIT_PER_WINDOW = + Number(process.env.SIGN_UP_LIMIT_PER_WINDOW) || 1 +const SIGN_UP_WINDOW_HOURS = Number(process.env.SIGN_UP_WINDOW_HOURS) || 24 + +/** + * Check if an identifier (IP address) has exceeded the successful sign-up limit + * @param identifier - IP address to check rate limiting for + * @returns Promise - true if rate limited, false if allowed + */ +export async function isSignUpRateLimited( + identifier: string +): Promise { + try { + const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier) + const attempts = await kv.get(key) + + if (!attempts) { + return false + } + + const attemptCount = parseInt(attempts as string, 10) + return attemptCount >= SIGN_UP_LIMIT_PER_WINDOW + } catch (error) { + l.error({ + key: 'sign_up_rate_limit:check_error', + error: serializeError(error), + context: { + identifier, + }, + }) + // on error, allow the request to proceed + return false + } +} + +/** + * Increment the successful sign-up counter for an identifier (IP) + * @param identifier - IP address to increment counter for + */ +export async function incrementSignUpAttempts( + identifier: string +): Promise { + try { + const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier) + const current = await kv.get(key) + const currentCount = current ? parseInt(current as string, 10) : 0 + const newCount = currentCount + 1 + + await kv.set(key, newCount.toString(), { + ex: SIGN_UP_WINDOW_HOURS * 3600, + }) + + l.debug({ + key: 'sign_up_rate_limit:increment', + context: { + identifier, + attempts: newCount, + }, + }) + } catch (error) { + l.error({ + key: 'sign_up_rate_limit:increment_error', + error: serializeError(error), + context: { + identifier, + }, + }) + } +} + +/** + * Reset the successful sign-up counter for an identifier (if needed) + * @param identifier - IP address to reset counter for + */ +export async function resetSignUpAttempts(identifier: string): Promise { + try { + const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier) + await kv.del(key) + + l.debug({ + key: 'sign_up_rate_limit:reset', + context: { + identifier, + }, + }) + } catch (error) { + l.error({ + key: 'sign_up_rate_limit:reset_error', + error: serializeError(error), + context: { + identifier, + }, + }) + } +} From 97d71d156a1eb623833b4cd619be7a3256648b7f Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 1 Sep 2025 12:37:46 +0200 Subject: [PATCH 02/20] patch: Update Sign-Up Rate Limiting Flag Logic --- src/configs/flags.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/configs/flags.ts b/src/configs/flags.ts index 56270c8da..041be422c 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -1,7 +1,7 @@ export const ALLOW_SEO_INDEXING = process.env.ALLOW_SEO_INDEXING === '1' export const VERBOSE = process.env.NEXT_PUBLIC_VERBOSE === '1' export const INCLUDE_BILLING = process.env.NEXT_PUBLIC_INCLUDE_BILLING === '1' - -// Feature flag: enable/disable sign-up rate limiting export const ENABLE_SIGN_UP_RATE_LIMITING = - process.env.ENABLE_SIGN_UP_RATE_LIMITING === '1' + process.env.ENABLE_SIGN_UP_RATE_LIMITING === '1' && + process.env.KV_REST_API_URL && + process.env.KV_REST_API_TOKEN From 2aeb96801c5718b5d112d89205f03741add55eba Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 1 Sep 2025 12:45:34 +0200 Subject: [PATCH 03/20] remove: manual reset since ttl resets --- src/server/auth/rate-limiting.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index 171f03235..b13028584 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -72,29 +72,3 @@ export async function incrementSignUpAttempts( }) } } - -/** - * Reset the successful sign-up counter for an identifier (if needed) - * @param identifier - IP address to reset counter for - */ -export async function resetSignUpAttempts(identifier: string): Promise { - try { - const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier) - await kv.del(key) - - l.debug({ - key: 'sign_up_rate_limit:reset', - context: { - identifier, - }, - }) - } catch (error) { - l.error({ - key: 'sign_up_rate_limit:reset_error', - error: serializeError(error), - context: { - identifier, - }, - }) - } -} From a6f4fa936bc63e5049bc04e99088d764dcfaa2c6 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 9 Sep 2025 10:52:03 +0200 Subject: [PATCH 04/20] feat: Add Upstash Rate Limiting for Sign-Up Process - Introduced `@upstash/ratelimit` dependency for improved rate limiting functionality. - Replaced manual increment logic with Upstash's rate limiting utility in the sign-up process. - Updated sign-up rate limiting checks to enhance logging and error handling. - Refactored related functions to streamline the rate limiting implementation. --- bun.lock | 5 +++ package.json | 1 + src/app/api/auth/confirm/route.ts | 39 +++++++++++++++-- src/configs/keys.ts | 1 - src/lib/utils/duration.ts | 40 ++++++++++++++++++ src/lib/utils/ratelimit.ts | 43 +++++++++++++++++++ src/server/auth/auth-actions.ts | 33 +++++++++++++-- src/server/auth/rate-limiting.ts | 70 +++++++++++++------------------ 8 files changed, 183 insertions(+), 49 deletions(-) create mode 100644 src/lib/utils/duration.ts create mode 100644 src/lib/utils/ratelimit.ts diff --git a/bun.lock b/bun.lock index 6df237a9c..760cda2d5 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "@theguild/remark-mermaid": "^0.2.0", "@types/mdx": "^2.0.13", "@types/micromatch": "^4.0.9", + "@upstash/ratelimit": "^2.0.6", "@vercel/analytics": "^1.5.0", "@vercel/kv": "^3.0.0", "@vercel/otel": "^1.13.0", @@ -1150,6 +1151,10 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], + + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.6", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg=="], + "@upstash/redis": ["@upstash/redis@1.35.3", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w=="], "@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="], diff --git a/package.json b/package.json index 288722b6c..e8ef4bcc5 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@theguild/remark-mermaid": "^0.2.0", "@types/mdx": "^2.0.13", "@types/micromatch": "^4.0.9", + "@upstash/ratelimit": "^2.0.6", "@vercel/analytics": "^1.5.0", "@vercel/kv": "^3.0.0", "@vercel/otel": "^1.13.0", diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 7bbb98987..fcde77eaa 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -3,7 +3,7 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l } from '@/lib/clients/logger/logger' import { createClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' -import { incrementSignUpAttempts } from '@/server/auth/rate-limiting' +import { isSignUpRateLimited } from '@/server/auth/rate-limiting' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' import { serializeError } from 'serialize-error' @@ -138,14 +138,47 @@ export async function GET(request: NextRequest) { } // increment counter for successful sign-up confirmations (rate limiting) - if (ENABLE_SIGN_UP_RATE_LIMITING && supabaseType === 'signup') { + if (ENABLE_SIGN_UP_RATE_LIMITING && supabaseType === 'email') { const ip = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || request.headers.get('x-real-ip') || 'unknown' - await incrementSignUpAttempts(ip) + l.debug( + { + key: 'sign_up_rate_limit:check', + context: { + ip: ip, + header: { + 'x-forwarded-for': request.headers.get('x-forwarded-for'), + 'cf-connecting-ip': request.headers.get('cf-connecting-ip'), + 'x-real-ip': request.headers.get('x-real-ip'), + }, + }, + }, + 'Sign-up rate limit check' + ) + + const isRateLimited = await isSignUpRateLimited(ip) + + if (isRateLimited) { + l.debug( + { + key: 'sign_up_rate_limited', + context: { + ip: ip, + }, + }, + 'Sign-up rate limited' + ) + + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + 'Too many sign-ups for now. Please try again later.' + ) + } } l.info({ diff --git a/src/configs/keys.ts b/src/configs/keys.ts index 90f112b9d..cac3db66a 100644 --- a/src/configs/keys.ts +++ b/src/configs/keys.ts @@ -19,5 +19,4 @@ export const KV_KEYS = { TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`, TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`, WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`, - SIGN_UP_RATE_LIMIT: (identifier: string) => `signup_rate_limit:${identifier}`, } diff --git a/src/lib/utils/duration.ts b/src/lib/utils/duration.ts new file mode 100644 index 000000000..76b9c2c0d --- /dev/null +++ b/src/lib/utils/duration.ts @@ -0,0 +1,40 @@ +// Taken from https://github.com/upstash/ratelimit/blob/main/src/duration.ts + +type Unit = 'ms' | 's' | 'm' | 'h' | 'd' +export type Duration = `${number} ${Unit}` | `${number}${Unit}` + +/** + * Convert a human readable duration to milliseconds + */ +export function ms(d: Duration): number { + const match = d.match(/^(\d+)\s?(ms|s|m|h|d)$/) + + if (!match || !match[1] || !match[2]) { + throw new Error(`Unable to parse window size: ${d}`) + } + + const time = Number.parseInt(match[1]) + const unit = match[2] as Unit + + switch (unit) { + case 'ms': { + return time + } + case 's': { + return time * 1000 + } + case 'm': { + return time * 1000 * 60 + } + case 'h': { + return time * 1000 * 60 * 60 + } + case 'd': { + return time * 1000 * 60 * 60 * 24 + } + + default: { + throw new Error(`Unable to parse window size: ${d}`) + } + } +} diff --git a/src/lib/utils/ratelimit.ts b/src/lib/utils/ratelimit.ts new file mode 100644 index 000000000..9bedc804b --- /dev/null +++ b/src/lib/utils/ratelimit.ts @@ -0,0 +1,43 @@ +import { kv } from '@/lib/clients/kv' +import { Ratelimit } from '@upstash/ratelimit' +import { Duration } from './duration' + +export interface RateLimitResult { + success: boolean + limit: number + reset: number + remaining: number +} + +/** + * Apply rate limiting to a key using @upstash/ratelimit + * @param key - Unique identifier for the rate limit (e.g., user ID, IP address) + * @param maxRequests - Maximum number of requests allowed in the window + * @param window - Time window for the rate limit (e.g., '1h', '24h', '1d') + * @returns RateLimitResult with success status and rate limit metadata + */ +export default async function ratelimit( + key: string | null, + maxRequests: number, + window: Duration +): Promise { + // Only apply rate limiting if KV is configured + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(maxRequests, window), + }) + + const result = await ratelimit.limit(`ratelimit_${key}`) + + return { + success: result.success, + limit: result.limit, + reset: result.reset, + remaining: result.remaining, + } + } + + // If rate limiting is not configured, allow all requests + return null +} diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index a4c0ede1f..d65d608ec 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -95,13 +95,40 @@ export const signUpAction = actionClient } } + const headersStore = await headers() + const ip = - (await headers()).get('x-forwarded-for') || - (await headers()).get('cf-connecting-ip') || - (await headers()).get('x-real-ip') || + headersStore.get('x-forwarded-for') || + headersStore.get('cf-connecting-ip') || + headersStore.get('x-real-ip') || 'unknown' + l.debug( + { + key: 'sign_up_attempt', + context: { + header: { + 'x-forwarded-for': headersStore.get('x-forwarded-for'), + 'cf-connecting-ip': headersStore.get('cf-connecting-ip'), + 'x-real-ip': headersStore.get('x-real-ip'), + }, + ip: ip, + }, + }, + 'Sign-up attempt' + ) + if (ENABLE_SIGN_UP_RATE_LIMITING && (await isSignUpRateLimited(ip))) { + l.debug( + { + key: 'sign_up_rate_limited', + context: { + ip: ip, + }, + }, + 'Sign-up rate limited' + ) + return returnServerError( 'Too many sign-ups for now. Please try again later.' ) diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index b13028584..121932443 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -1,12 +1,16 @@ -import { KV_KEYS } from '@/configs/keys' -import { kv } from '@/lib/clients/kv' import { l } from '@/lib/clients/logger/logger' +import { Duration } from '@/lib/utils/duration' +import ratelimit from '@/lib/utils/ratelimit' import { serializeError } from 'serialize-error' +// Configuration from environment variables const SIGN_UP_LIMIT_PER_WINDOW = Number(process.env.SIGN_UP_LIMIT_PER_WINDOW) || 1 const SIGN_UP_WINDOW_HOURS = Number(process.env.SIGN_UP_WINDOW_HOURS) || 24 +// Convert hours to Duration format +const SIGN_UP_WINDOW: Duration = `${SIGN_UP_WINDOW_HOURS}h` + /** * Check if an identifier (IP address) has exceeded the successful sign-up limit * @param identifier - IP address to check rate limiting for @@ -16,15 +20,32 @@ export async function isSignUpRateLimited( identifier: string ): Promise { try { - const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier) - const attempts = await kv.get(key) + const result = await ratelimit( + `signup:${identifier}`, + SIGN_UP_LIMIT_PER_WINDOW, + SIGN_UP_WINDOW + ) - if (!attempts) { + // If rate limiting is not configured, allow the request + if (!result) { return false } - const attemptCount = parseInt(attempts as string, 10) - return attemptCount >= SIGN_UP_LIMIT_PER_WINDOW + const isRateLimited = !result.success + + if (isRateLimited) { + l.debug({ + key: 'sign_up_rate_limit:blocked', + context: { + identifier, + limit: result.limit, + remaining: result.remaining, + reset: result.reset, + }, + }) + } + + return isRateLimited } catch (error) { l.error({ key: 'sign_up_rate_limit:check_error', @@ -37,38 +58,3 @@ export async function isSignUpRateLimited( return false } } - -/** - * Increment the successful sign-up counter for an identifier (IP) - * @param identifier - IP address to increment counter for - */ -export async function incrementSignUpAttempts( - identifier: string -): Promise { - try { - const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier) - const current = await kv.get(key) - const currentCount = current ? parseInt(current as string, 10) : 0 - const newCount = currentCount + 1 - - await kv.set(key, newCount.toString(), { - ex: SIGN_UP_WINDOW_HOURS * 3600, - }) - - l.debug({ - key: 'sign_up_rate_limit:increment', - context: { - identifier, - attempts: newCount, - }, - }) - } catch (error) { - l.error({ - key: 'sign_up_rate_limit:increment_error', - error: serializeError(error), - context: { - identifier, - }, - }) - } -} From 1c8e1ed052c915d1e33d03b46de61bd46a5b8171 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 9 Sep 2025 12:37:23 +0200 Subject: [PATCH 05/20] refactor: Reorganize OTP verification logic in auth confirmation route --- src/app/api/auth/confirm/route.ts | 82 +++++++++++++++---------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index fcde77eaa..0067753ee 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -96,47 +96,6 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(next) - const supabase = await createClient() - - const { error, data } = await supabase.auth.verifyOtp({ - type: supabaseType, - token_hash: supabaseTokenHash, - }) - - if (error) { - l.error({ - key: 'auth_confirm:supabase_error', - message: error.message, - error: serializeError(error), - context: { - supabase_token_hash: supabaseTokenHash - ? `${supabaseTokenHash.slice(0, 10)}...` - : null, - supabaseType, - supabaseRedirectTo, - redirectUrl: redirectUrl.toString(), - }, - }) - - let errorMessage = 'Invalid Token' - if (error.status === 403 && error.code === 'otp_expired') { - errorMessage = 'Email link has expired. Please request a new one.' - } - - return encodedRedirect( - 'error', - dashboardSignInUrl.toString(), - errorMessage - ) - } - - // handle re-auth - if (redirectUrl.pathname === PROTECTED_URLS.ACCOUNT_SETTINGS) { - redirectUrl.searchParams.set('reauth', '1') - - return NextResponse.redirect(redirectUrl.toString()) - } - // increment counter for successful sign-up confirmations (rate limiting) if (ENABLE_SIGN_UP_RATE_LIMITING && supabaseType === 'email') { const ip = @@ -181,6 +140,47 @@ export async function GET(request: NextRequest) { } } + const supabase = await createClient() + + const { error, data } = await supabase.auth.verifyOtp({ + type: supabaseType, + token_hash: supabaseTokenHash, + }) + + if (error) { + l.error({ + key: 'auth_confirm:supabase_error', + message: error.message, + error: serializeError(error), + context: { + supabase_token_hash: supabaseTokenHash + ? `${supabaseTokenHash.slice(0, 10)}...` + : null, + supabaseType, + supabaseRedirectTo, + redirectUrl: redirectUrl.toString(), + }, + }) + + let errorMessage = 'Invalid Token' + if (error.status === 403 && error.code === 'otp_expired') { + errorMessage = 'Email link has expired. Please request a new one.' + } + + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + errorMessage + ) + } + + // handle re-auth + if (redirectUrl.pathname === PROTECTED_URLS.ACCOUNT_SETTINGS) { + redirectUrl.searchParams.set('reauth', '1') + + return NextResponse.redirect(redirectUrl.toString()) + } + l.info({ key: 'auth_confirm:success', user_id: data?.user?.id, From 89caac527e956444d14319918c695257d77ee7b8 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 9 Sep 2025 13:00:38 +0200 Subject: [PATCH 06/20] refactor: Update rate limiting logic and environment schema for sign-up process - Renamed `TruethyOrFalsy` to `NumericBoolean` for clarity in environment schema. - Added new fields for sign-up attempts limit and window in the environment schema. - Updated rate limiting functions to improve clarity and logging for sign-up attempts. - Refactored related code to enhance consistency in rate limiting checks. --- src/lib/env.ts | 15 +++--- src/lib/utils/ratelimit.ts | 5 +- src/server/auth/RATE_LIMITING.md | 73 ++++++++++++++++++++++++++++ src/server/auth/auth-actions.ts | 13 +++-- src/server/auth/rate-limiting.ts | 81 ++++++++++++++++++++++++++++++-- 5 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 src/server/auth/RATE_LIMITING.md diff --git a/src/lib/env.ts b/src/lib/env.ts index 38e639060..4e9c575d9 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -const TruethyOrFalsy = z.enum(['1', '0']) +const NumericBoolean = z.enum(['1', '0']) export const serverSchema = z.object({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), @@ -11,7 +11,10 @@ export const serverSchema = z.object({ BILLING_API_URL: z.string().url().optional(), ZEROBOUNCE_API_KEY: z.string().optional(), - ENABLE_SIGN_UP_RATE_LIMITING: TruethyOrFalsy.optional(), + + ENABLE_SIGN_UP_RATE_LIMITING: NumericBoolean.optional(), + SIGN_UP_ATTEMPTS_LIMIT: z.coerce.number().optional(), + SIGN_UP_ATTEMPTS_WINDOW_HOURS: z.coerce.number().optional(), SIGN_UP_LIMIT_PER_WINDOW: z.coerce.number().optional(), SIGN_UP_WINDOW_HOURS: z.coerce.number().optional(), @@ -46,10 +49,10 @@ export const clientSchema = z.object({ NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1).optional(), - NEXT_PUBLIC_INCLUDE_BILLING: TruethyOrFalsy.optional(), - NEXT_PUBLIC_SCAN: TruethyOrFalsy.optional(), - NEXT_PUBLIC_MOCK_DATA: TruethyOrFalsy.optional(), - NEXT_PUBLIC_VERBOSE: TruethyOrFalsy.optional(), + NEXT_PUBLIC_INCLUDE_BILLING: NumericBoolean.optional(), + NEXT_PUBLIC_SCAN: NumericBoolean.optional(), + NEXT_PUBLIC_MOCK_DATA: NumericBoolean.optional(), + NEXT_PUBLIC_VERBOSE: NumericBoolean.optional(), }) export const testEnvSchema = z.object({ diff --git a/src/lib/utils/ratelimit.ts b/src/lib/utils/ratelimit.ts index 9bedc804b..d28b8170b 100644 --- a/src/lib/utils/ratelimit.ts +++ b/src/lib/utils/ratelimit.ts @@ -17,7 +17,7 @@ export interface RateLimitResult { * @returns RateLimitResult with success status and rate limit metadata */ export default async function ratelimit( - key: string | null, + key: string, maxRequests: number, window: Duration ): Promise { @@ -28,7 +28,8 @@ export default async function ratelimit( limiter: Ratelimit.slidingWindow(maxRequests, window), }) - const result = await ratelimit.limit(`ratelimit_${key}`) + // the package applie "@upstash/ratelimit" prefixed to the key + const result = await ratelimit.limit(key) return { success: result.success, diff --git a/src/server/auth/RATE_LIMITING.md b/src/server/auth/RATE_LIMITING.md new file mode 100644 index 000000000..effbcb7c4 --- /dev/null +++ b/src/server/auth/RATE_LIMITING.md @@ -0,0 +1,73 @@ +# Rate Limiting Documentation + +This system implements two separate rate limiters for the sign-up flow to prevent different types of abuse: + +## 1. Sign-Up Attempts Rate Limiter + +**Purpose**: Prevents spam/abuse of the sign-up endpoint by limiting how many times someone can attempt to create an account. + +**When Applied**: During the initial sign-up request (before email confirmation) + +**Configuration**: +- `SIGN_UP_ATTEMPTS_LIMIT` - Maximum number of sign-up attempts allowed (default: 10) +- `SIGN_UP_ATTEMPTS_WINDOW_HOURS` - Time window in hours for attempt limiting (default: 1) + +**Example**: With defaults, allows 10 sign-up attempts per hour per IP address. + +## 2. Actual Sign-Ups Rate Limiter + +**Purpose**: Limits the number of confirmed accounts that can be created to prevent mass account creation. + +**When Applied**: During email confirmation (when the user clicks the confirmation link) + +**Configuration**: +- `SIGN_UP_LIMIT_PER_WINDOW` - Maximum number of confirmed sign-ups allowed (default: 1) +- `SIGN_UP_WINDOW_HOURS` - Time window in hours for sign-up limiting (default: 24) + +**Example**: With defaults, allows 1 confirmed account creation per day per IP address. + +## Environment Variables + +Add these to your `.env` file: + +```bash +# Enable rate limiting (required along with KV configuration) +ENABLE_SIGN_UP_RATE_LIMITING=1 + +# Redis/KV configuration (required for rate limiting) +KV_REST_API_URL=your_kv_url +KV_REST_API_TOKEN=your_kv_token + +# Sign-up attempts rate limiting +SIGN_UP_ATTEMPTS_LIMIT=10 # Max attempts per window +SIGN_UP_ATTEMPTS_WINDOW_HOURS=1 # Window size in hours + +# Actual sign-ups rate limiting +SIGN_UP_LIMIT_PER_WINDOW=1 # Max confirmed sign-ups per window +SIGN_UP_WINDOW_HOURS=24 # Window size in hours +``` + +## How It Works + +1. **User attempts to sign up** → Check against attempts rate limiter + - If rate limited: Return "Too many sign-up attempts. Please try again later." + - If allowed: Proceed with sign-up + +2. **User confirms email** → Check against actual sign-ups rate limiter + - If rate limited: Return "Too many sign-ups for now. Please try again later." + - If allowed: Complete account creation + +## Key Prefixes + +The rate limiters use different Redis key prefixes to track separate counters: +- Sign-up attempts: `signup-attempt:{ip}` +- Actual sign-ups: `signup:{ip}` + +This ensures the two rate limiters operate independently. + +## Benefits + +- **Better User Experience**: Users can retry if they make mistakes (e.g., wrong password format) +- **Enhanced Security**: Prevents both spam attempts and mass account creation +- **Flexible Configuration**: Each rate limiter can be tuned independently +- **Clear Separation**: Different Redis keys prevent interference between the two systems diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index d65d608ec..54a25b9a2 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -18,7 +18,7 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' -import { isSignUpRateLimited } from './rate-limiting' +import { isSignUpAttemptRateLimited } from './rate-limiting' export const signInWithOAuthAction = actionClient .schema( @@ -118,19 +118,22 @@ export const signUpAction = actionClient 'Sign-up attempt' ) - if (ENABLE_SIGN_UP_RATE_LIMITING && (await isSignUpRateLimited(ip))) { + if ( + ENABLE_SIGN_UP_RATE_LIMITING && + (await isSignUpAttemptRateLimited(ip)) + ) { l.debug( { - key: 'sign_up_rate_limited', + key: 'sign_up_attempt_rate_limited', context: { ip: ip, }, }, - 'Sign-up rate limited' + 'Sign-up attempt rate limited' ) return returnServerError( - 'Too many sign-ups for now. Please try again later.' + 'Too many sign-up attempts. Please try again later.' ) } diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index 121932443..6fe5b31c7 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -3,16 +3,72 @@ import { Duration } from '@/lib/utils/duration' import ratelimit from '@/lib/utils/ratelimit' import { serializeError } from 'serialize-error' -// Configuration from environment variables +// Configuration for sign-up attempts (prevent spam) +const SIGN_UP_ATTEMPTS_LIMIT = Number(process.env.SIGN_UP_ATTEMPTS_LIMIT) || 10 +const SIGN_UP_ATTEMPTS_WINDOW_HOURS = + Number(process.env.SIGN_UP_ATTEMPTS_WINDOW_HOURS) || 1 + +// Configuration for actual sign-ups (limit account creation) const SIGN_UP_LIMIT_PER_WINDOW = Number(process.env.SIGN_UP_LIMIT_PER_WINDOW) || 1 const SIGN_UP_WINDOW_HOURS = Number(process.env.SIGN_UP_WINDOW_HOURS) || 24 -// Convert hours to Duration format +// Convert to Duration format +const SIGN_UP_ATTEMPTS_WINDOW: Duration = `${SIGN_UP_ATTEMPTS_WINDOW_HOURS}h` const SIGN_UP_WINDOW: Duration = `${SIGN_UP_WINDOW_HOURS}h` /** - * Check if an identifier (IP address) has exceeded the successful sign-up limit + * Check if an identifier (IP address) has exceeded the sign-up attempts limit + * This prevents spam/abuse of the sign-up endpoint + * @param identifier - IP address to check rate limiting for + * @returns Promise - true if rate limited, false if allowed + */ +export async function isSignUpAttemptRateLimited( + identifier: string +): Promise { + try { + const result = await ratelimit( + `signup-attempt:${identifier}`, + SIGN_UP_ATTEMPTS_LIMIT, + SIGN_UP_ATTEMPTS_WINDOW + ) + + // If rate limiting is not configured, allow the request + if (!result) { + return false + } + + const isRateLimited = !result.success + + if (isRateLimited) { + l.debug({ + key: 'sign_up_attempt_rate_limit:blocked', + context: { + identifier, + limit: result.limit, + remaining: result.remaining, + reset: result.reset, + }, + }) + } + + return isRateLimited + } catch (error) { + l.error({ + key: 'sign_up_attempt_rate_limit:check_error', + error: serializeError(error), + context: { + identifier, + }, + }) + // on error, allow the request to proceed + return false + } +} + +/** + * Check if an identifier (IP address) has exceeded the actual sign-up limit + * This limits the number of confirmed accounts created per time window * @param identifier - IP address to check rate limiting for * @returns Promise - true if rate limited, false if allowed */ @@ -58,3 +114,22 @@ export async function isSignUpRateLimited( return false } } + +/** + * Log rate limit configuration on startup (for debugging) + */ +export function logRateLimitConfiguration() { + l.info({ + key: 'rate_limit_configuration', + context: { + sign_up_attempts: { + limit: SIGN_UP_ATTEMPTS_LIMIT, + window: SIGN_UP_ATTEMPTS_WINDOW, + }, + sign_ups: { + limit: SIGN_UP_LIMIT_PER_WINDOW, + window: SIGN_UP_WINDOW, + }, + }, + }) +} From 424358eb94be38b6938b4dea1138ea9cb93e1d92 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 9 Sep 2025 13:53:14 +0200 Subject: [PATCH 07/20] refactor: Update environment variables and rate limiting configuration for sign-up process - Renamed `SIGN_UP_ATTEMPTS_LIMIT` to `SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW` for clarity. - Added new environment variables for sign-up rate limiting configuration. - Updated documentation and related code to reflect the changes in rate limiting logic. --- .env.example | 7 +++++++ src/lib/env.ts | 2 +- src/server/auth/RATE_LIMITING.md | 4 ++-- src/server/auth/rate-limiting.ts | 7 ++++--- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 0bd9fcbf3..442ed79a0 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,13 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### OPTIONAL SERVER ENVIRONMENT VARIABLES ### ================================= +### Auth Rate Limiting +# ENABLE_SIGN_UP_RATE_LIMITING=1 +# SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW=10 +# SIGN_UP_ATTEMPTS_WINDOW_HOURS=6 +# SIGN_UP_LIMIT_PER_WINDOW=1 +# SIGN_UP_WINDOW_HOURS=24 + ### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1) # BILLING_API_URL=https://billing.e2b.dev diff --git a/src/lib/env.ts b/src/lib/env.ts index 4e9c575d9..b3888b404 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -13,7 +13,7 @@ export const serverSchema = z.object({ ZEROBOUNCE_API_KEY: z.string().optional(), ENABLE_SIGN_UP_RATE_LIMITING: NumericBoolean.optional(), - SIGN_UP_ATTEMPTS_LIMIT: z.coerce.number().optional(), + SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW: z.coerce.number().optional(), SIGN_UP_ATTEMPTS_WINDOW_HOURS: z.coerce.number().optional(), SIGN_UP_LIMIT_PER_WINDOW: z.coerce.number().optional(), SIGN_UP_WINDOW_HOURS: z.coerce.number().optional(), diff --git a/src/server/auth/RATE_LIMITING.md b/src/server/auth/RATE_LIMITING.md index effbcb7c4..a0c692975 100644 --- a/src/server/auth/RATE_LIMITING.md +++ b/src/server/auth/RATE_LIMITING.md @@ -9,7 +9,7 @@ This system implements two separate rate limiters for the sign-up flow to preven **When Applied**: During the initial sign-up request (before email confirmation) **Configuration**: -- `SIGN_UP_ATTEMPTS_LIMIT` - Maximum number of sign-up attempts allowed (default: 10) +- `SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW` - Maximum number of sign-up attempts allowed (default: 10) - `SIGN_UP_ATTEMPTS_WINDOW_HOURS` - Time window in hours for attempt limiting (default: 1) **Example**: With defaults, allows 10 sign-up attempts per hour per IP address. @@ -39,7 +39,7 @@ KV_REST_API_URL=your_kv_url KV_REST_API_TOKEN=your_kv_token # Sign-up attempts rate limiting -SIGN_UP_ATTEMPTS_LIMIT=10 # Max attempts per window +SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW=10 # Max attempts per window SIGN_UP_ATTEMPTS_WINDOW_HOURS=1 # Window size in hours # Actual sign-ups rate limiting diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index 6fe5b31c7..95ef9ba21 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -4,7 +4,8 @@ import ratelimit from '@/lib/utils/ratelimit' import { serializeError } from 'serialize-error' // Configuration for sign-up attempts (prevent spam) -const SIGN_UP_ATTEMPTS_LIMIT = Number(process.env.SIGN_UP_ATTEMPTS_LIMIT) || 10 +const SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW = + Number(process.env.SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW) || 10 const SIGN_UP_ATTEMPTS_WINDOW_HOURS = Number(process.env.SIGN_UP_ATTEMPTS_WINDOW_HOURS) || 1 @@ -29,7 +30,7 @@ export async function isSignUpAttemptRateLimited( try { const result = await ratelimit( `signup-attempt:${identifier}`, - SIGN_UP_ATTEMPTS_LIMIT, + SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW, SIGN_UP_ATTEMPTS_WINDOW ) @@ -123,7 +124,7 @@ export function logRateLimitConfiguration() { key: 'rate_limit_configuration', context: { sign_up_attempts: { - limit: SIGN_UP_ATTEMPTS_LIMIT, + limit: SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW, window: SIGN_UP_ATTEMPTS_WINDOW, }, sign_ups: { From 228ae45af568edbe098ca758a0551b53151fd861 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 9 Sep 2025 14:01:13 +0200 Subject: [PATCH 08/20] fix: Validate IP address in auth confirmation and sign-up actions - Added checks to ensure a valid IP address is retrieved in both the auth confirmation route and sign-up action. - Returns an error response if the IP address is invalid, improving error handling and user feedback. --- src/app/api/auth/confirm/route.ts | 11 +++++++++-- src/server/auth/auth-actions.ts | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 0067753ee..3b3fb979b 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -101,8 +101,15 @@ export async function GET(request: NextRequest) { const ip = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || - request.headers.get('x-real-ip') || - 'unknown' + request.headers.get('x-real-ip') + + if (!ip) { + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + 'Invalid IP address.' + ) + } l.debug( { diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 54a25b9a2..a505e0b4e 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -100,8 +100,11 @@ export const signUpAction = actionClient const ip = headersStore.get('x-forwarded-for') || headersStore.get('cf-connecting-ip') || - headersStore.get('x-real-ip') || - 'unknown' + headersStore.get('x-real-ip') + + if (!ip) { + return returnServerError('Invalid IP address.') + } l.debug( { From 87fc2c3300aab403e540928d5945cc16a1c0a7d8 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 9 Sep 2025 14:36:29 +0200 Subject: [PATCH 09/20] refactor: Enhance rate limiting configuration with positive number validation - Introduced a custom string type for validating positive numbers in the environment schema. - Updated rate limiting configuration to ensure that limits and windows are positive numbers. - Improved documentation to reflect the new validation requirements for rate limiting parameters. --- src/lib/env.ts | 21 ++++++++++--- src/server/auth/RATE_LIMITING.md | 16 +++++----- src/server/auth/rate-limiting.ts | 51 +++++++++++++++++++++++++++----- 3 files changed, 69 insertions(+), 19 deletions(-) diff --git a/src/lib/env.ts b/src/lib/env.ts index b3888b404..ddae266d5 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -2,6 +2,19 @@ import { z } from 'zod' const NumericBoolean = z.enum(['1', '0']) +// Custom string type that validates as a positive number but remains a string +// We keep it as string because we don't actually use the validated values directly, +// but only validate that they can be parsed as positive numbers +const StringPositiveNumber = z.string().refine( + (val) => { + const num = Number(val) + return !isNaN(num) && num > 0 + }, + { + message: 'Must be a string that can be parsed as a positive number', + } +) + export const serverSchema = z.object({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), INFRA_API_URL: z.string().url(), @@ -13,10 +26,10 @@ export const serverSchema = z.object({ ZEROBOUNCE_API_KEY: z.string().optional(), ENABLE_SIGN_UP_RATE_LIMITING: NumericBoolean.optional(), - SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW: z.coerce.number().optional(), - SIGN_UP_ATTEMPTS_WINDOW_HOURS: z.coerce.number().optional(), - SIGN_UP_LIMIT_PER_WINDOW: z.coerce.number().optional(), - SIGN_UP_WINDOW_HOURS: z.coerce.number().optional(), + SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW: StringPositiveNumber.optional(), + SIGN_UP_ATTEMPTS_WINDOW_HOURS: StringPositiveNumber.optional(), + SIGN_UP_LIMIT_PER_WINDOW: StringPositiveNumber.optional(), + SIGN_UP_WINDOW_HOURS: StringPositiveNumber.optional(), OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), diff --git a/src/server/auth/RATE_LIMITING.md b/src/server/auth/RATE_LIMITING.md index a0c692975..0c45e8772 100644 --- a/src/server/auth/RATE_LIMITING.md +++ b/src/server/auth/RATE_LIMITING.md @@ -9,8 +9,8 @@ This system implements two separate rate limiters for the sign-up flow to preven **When Applied**: During the initial sign-up request (before email confirmation) **Configuration**: -- `SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW` - Maximum number of sign-up attempts allowed (default: 10) -- `SIGN_UP_ATTEMPTS_WINDOW_HOURS` - Time window in hours for attempt limiting (default: 1) +- `SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW` - Maximum number of sign-up attempts allowed (default: 10, must be positive) +- `SIGN_UP_ATTEMPTS_WINDOW_HOURS` - Time window in hours for attempt limiting (default: 1, must be positive) **Example**: With defaults, allows 10 sign-up attempts per hour per IP address. @@ -21,8 +21,8 @@ This system implements two separate rate limiters for the sign-up flow to preven **When Applied**: During email confirmation (when the user clicks the confirmation link) **Configuration**: -- `SIGN_UP_LIMIT_PER_WINDOW` - Maximum number of confirmed sign-ups allowed (default: 1) -- `SIGN_UP_WINDOW_HOURS` - Time window in hours for sign-up limiting (default: 24) +- `SIGN_UP_LIMIT_PER_WINDOW` - Maximum number of confirmed sign-ups allowed (default: 1, must be positive) +- `SIGN_UP_WINDOW_HOURS` - Time window in hours for sign-up limiting (default: 24, must be positive) **Example**: With defaults, allows 1 confirmed account creation per day per IP address. @@ -39,12 +39,12 @@ KV_REST_API_URL=your_kv_url KV_REST_API_TOKEN=your_kv_token # Sign-up attempts rate limiting -SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW=10 # Max attempts per window -SIGN_UP_ATTEMPTS_WINDOW_HOURS=1 # Window size in hours +SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW=10 # Max attempts per window (must be positive) +SIGN_UP_ATTEMPTS_WINDOW_HOURS=1 # Window size in hours (must be positive) # Actual sign-ups rate limiting -SIGN_UP_LIMIT_PER_WINDOW=1 # Max confirmed sign-ups per window -SIGN_UP_WINDOW_HOURS=24 # Window size in hours +SIGN_UP_LIMIT_PER_WINDOW=1 # Max confirmed sign-ups per window (must be positive) +SIGN_UP_WINDOW_HOURS=24 # Window size in hours (must be positive) ``` ## How It Works diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index 95ef9ba21..42a92d1b3 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -3,16 +3,53 @@ import { Duration } from '@/lib/utils/duration' import ratelimit from '@/lib/utils/ratelimit' import { serializeError } from 'serialize-error' +// Helper function to parse and validate positive numbers +function parsePositiveNumber( + value: string | undefined, + defaultValue: number, + name: string +): number { + if (!value) return defaultValue + + const parsed = Number(value) + if (isNaN(parsed) || parsed <= 0) { + l.warn({ + key: 'rate_limit_config:invalid_value', + context: { + variable: name, + value, + defaultUsed: defaultValue, + }, + }) + return defaultValue + } + + return parsed +} + // Configuration for sign-up attempts (prevent spam) -const SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW = - Number(process.env.SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW) || 10 -const SIGN_UP_ATTEMPTS_WINDOW_HOURS = - Number(process.env.SIGN_UP_ATTEMPTS_WINDOW_HOURS) || 1 +const SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW = parsePositiveNumber( + process.env.SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW as string | undefined, + 10, + 'SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW' +) +const SIGN_UP_ATTEMPTS_WINDOW_HOURS = parsePositiveNumber( + process.env.SIGN_UP_ATTEMPTS_WINDOW_HOURS as string | undefined, + 1, + 'SIGN_UP_ATTEMPTS_WINDOW_HOURS' +) // Configuration for actual sign-ups (limit account creation) -const SIGN_UP_LIMIT_PER_WINDOW = - Number(process.env.SIGN_UP_LIMIT_PER_WINDOW) || 1 -const SIGN_UP_WINDOW_HOURS = Number(process.env.SIGN_UP_WINDOW_HOURS) || 24 +const SIGN_UP_LIMIT_PER_WINDOW = parsePositiveNumber( + process.env.SIGN_UP_LIMIT_PER_WINDOW as string | undefined, + 1, + 'SIGN_UP_LIMIT_PER_WINDOW' +) +const SIGN_UP_WINDOW_HOURS = parsePositiveNumber( + process.env.SIGN_UP_WINDOW_HOURS as string | undefined, + 24, + 'SIGN_UP_WINDOW_HOURS' +) // Convert to Duration format const SIGN_UP_ATTEMPTS_WINDOW: Duration = `${SIGN_UP_ATTEMPTS_WINDOW_HOURS}h` From 2a92b5a46448538b84f65860850170e194540e1a Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 9 Sep 2025 14:47:25 +0200 Subject: [PATCH 10/20] refactor: Improve IP address handling and logging in rate limiting - Refactored IP address extraction in the auth confirmation route and sign-up action to use a dedicated utility function. - Enhanced logging to capture errors when IP headers are missing in production environments. - Updated documentation to clarify the handling of IP addresses in the rate limiting system, including support for development environments. --- src/app/api/auth/confirm/route.ts | 45 +++++----------------- src/lib/env.ts | 4 +- src/lib/utils/ip-extraction.ts | 30 +++++++++++++++ src/lib/utils/ratelimit.ts | 5 +-- src/server/auth/RATE_LIMITING.md | 22 ++++++++++- src/server/auth/auth-actions.ts | 44 +++++++-------------- src/server/auth/rate-limiting.ts | 63 +++---------------------------- 7 files changed, 83 insertions(+), 130 deletions(-) create mode 100644 src/lib/utils/ip-extraction.ts diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 3b3fb979b..67b195f5b 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -3,6 +3,7 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l } from '@/lib/clients/logger/logger' import { createClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' +import { extractClientIp, isDevelopmentIp } from '@/lib/utils/ip-extraction' import { isSignUpRateLimited } from '@/server/auth/rate-limiting' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' @@ -96,49 +97,23 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(next) - // increment counter for successful sign-up confirmations (rate limiting) + // check rate limit for email confirmations if (ENABLE_SIGN_UP_RATE_LIMITING && supabaseType === 'email') { - const ip = - request.headers.get('x-forwarded-for') || - request.headers.get('cf-connecting-ip') || - request.headers.get('x-real-ip') + const ip = extractClientIp(request.headers) - if (!ip) { - return encodedRedirect( - 'error', - dashboardSignInUrl.toString(), - 'Invalid IP address.' - ) - } - - l.debug( - { - key: 'sign_up_rate_limit:check', + // log error if no ip headers found in production + if (isDevelopmentIp(ip) && process.env.NODE_ENV === 'production') { + l.error({ + key: 'sign_up_confirm:no_ip_headers', context: { - ip: ip, - header: { - 'x-forwarded-for': request.headers.get('x-forwarded-for'), - 'cf-connecting-ip': request.headers.get('cf-connecting-ip'), - 'x-real-ip': request.headers.get('x-real-ip'), - }, + message: 'no ip headers found in production', }, - }, - 'Sign-up rate limit check' - ) + }) + } const isRateLimited = await isSignUpRateLimited(ip) if (isRateLimited) { - l.debug( - { - key: 'sign_up_rate_limited', - context: { - ip: ip, - }, - }, - 'Sign-up rate limited' - ) - return encodedRedirect( 'error', dashboardSignInUrl.toString(), diff --git a/src/lib/env.ts b/src/lib/env.ts index ddae266d5..b6664cd46 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -2,9 +2,7 @@ import { z } from 'zod' const NumericBoolean = z.enum(['1', '0']) -// Custom string type that validates as a positive number but remains a string -// We keep it as string because we don't actually use the validated values directly, -// but only validate that they can be parsed as positive numbers +// string that must parse as a positive number const StringPositiveNumber = z.string().refine( (val) => { const num = Number(val) diff --git a/src/lib/utils/ip-extraction.ts b/src/lib/utils/ip-extraction.ts new file mode 100644 index 000000000..9c69889a8 --- /dev/null +++ b/src/lib/utils/ip-extraction.ts @@ -0,0 +1,30 @@ +export function extractClientIp(headers: { + get(name: string): string | null +}): string { + const xForwardedFor = headers.get('x-forwarded-for') + const cfConnectingIp = headers.get('cf-connecting-ip') + const xRealIp = headers.get('x-real-ip') + + // parse x-forwarded-for: "client, proxy1, proxy2" -> extract client ip + if (xForwardedFor) { + const ips = xForwardedFor.split(',').map((ip) => ip.trim()) + if (ips[0]) { + return ips[0] + } + } + + if (cfConnectingIp) { + return cfConnectingIp + } + + if (xRealIp) { + return xRealIp + } + + // fallback for development + return 'development-no-ip' +} + +export function isDevelopmentIp(ip: string): boolean { + return ip === 'development-no-ip' +} \ No newline at end of file diff --git a/src/lib/utils/ratelimit.ts b/src/lib/utils/ratelimit.ts index d28b8170b..4dcde1bdc 100644 --- a/src/lib/utils/ratelimit.ts +++ b/src/lib/utils/ratelimit.ts @@ -21,14 +21,13 @@ export default async function ratelimit( maxRequests: number, window: Duration ): Promise { - // Only apply rate limiting if KV is configured + // only apply rate limiting if kv is configured if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { const ratelimit = new Ratelimit({ redis: kv, limiter: Ratelimit.slidingWindow(maxRequests, window), }) - // the package applie "@upstash/ratelimit" prefixed to the key const result = await ratelimit.limit(key) return { @@ -39,6 +38,6 @@ export default async function ratelimit( } } - // If rate limiting is not configured, allow all requests + // if rate limiting is not configured, allow all requests return null } diff --git a/src/server/auth/RATE_LIMITING.md b/src/server/auth/RATE_LIMITING.md index 0c45e8772..992f45700 100644 --- a/src/server/auth/RATE_LIMITING.md +++ b/src/server/auth/RATE_LIMITING.md @@ -1,6 +1,10 @@ # Rate Limiting Documentation -This system implements two separate rate limiters for the sign-up flow to prevent different types of abuse: +This system implements two separate rate limiters for the sign-up flow to prevent different types of abuse. + +## Important: Development Environment Support + +The rate limiting system now works seamlessly in development environments without proxy headers. When IP headers are missing, the system falls back to a development identifier that still allows rate limiting to function properly. ## 1. Sign-Up Attempts Rate Limiter @@ -65,9 +69,25 @@ The rate limiters use different Redis key prefixes to track separate counters: This ensures the two rate limiters operate independently. +## IP Address Handling + +The system properly handles various IP header formats: + +1. **x-forwarded-for**: Parsed to extract the first IP from comma-separated list (client IP) +2. **cf-connecting-ip**: Used directly (Cloudflare header) +3. **x-real-ip**: Used directly (generic proxy header) +4. **Development fallback**: Uses 'development-no-ip' when no headers are present + +This ensures the rate limiting works in: +- Production environments with proxies/CDNs +- Development environments without proxy headers +- Local testing scenarios + ## Benefits - **Better User Experience**: Users can retry if they make mistakes (e.g., wrong password format) - **Enhanced Security**: Prevents both spam attempts and mass account creation - **Flexible Configuration**: Each rate limiter can be tuned independently - **Clear Separation**: Different Redis keys prevent interference between the two systems +- **Development Support**: Works seamlessly in local development without proxy configuration +- **Proper IP Parsing**: Correctly handles multi-IP headers from proxy chains diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index a505e0b4e..ea5b8770f 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -9,6 +9,7 @@ import { createClient } from '@/lib/clients/supabase/server' import { relativeUrlSchema } from '@/lib/schemas/url' import { returnServerError } from '@/lib/utils/action' import { encodedRedirect } from '@/lib/utils/auth' +import { extractClientIp, isDevelopmentIp } from '@/lib/utils/ip-extraction' import { shouldWarnAboutAlternateEmail, validateEmail, @@ -96,45 +97,26 @@ export const signUpAction = actionClient } const headersStore = await headers() + const ip = extractClientIp(headersStore) - const ip = - headersStore.get('x-forwarded-for') || - headersStore.get('cf-connecting-ip') || - headersStore.get('x-real-ip') - - if (!ip) { - return returnServerError('Invalid IP address.') - } - - l.debug( - { - key: 'sign_up_attempt', + // log error if no ip headers found + if ( + isDevelopmentIp(ip) && + ENABLE_SIGN_UP_RATE_LIMITING && + process.env.NODE_ENV === 'production' + ) { + l.error({ + key: 'sign_up_attempt:no_ip_headers', context: { - header: { - 'x-forwarded-for': headersStore.get('x-forwarded-for'), - 'cf-connecting-ip': headersStore.get('cf-connecting-ip'), - 'x-real-ip': headersStore.get('x-real-ip'), - }, - ip: ip, + message: 'no ip headers found in production', }, - }, - 'Sign-up attempt' - ) + }) + } if ( ENABLE_SIGN_UP_RATE_LIMITING && (await isSignUpAttemptRateLimited(ip)) ) { - l.debug( - { - key: 'sign_up_attempt_rate_limited', - context: { - ip: ip, - }, - }, - 'Sign-up attempt rate limited' - ) - return returnServerError( 'Too many sign-up attempts. Please try again later.' ) diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index 42a92d1b3..a894e1b50 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -3,7 +3,7 @@ import { Duration } from '@/lib/utils/duration' import ratelimit from '@/lib/utils/ratelimit' import { serializeError } from 'serialize-error' -// Helper function to parse and validate positive numbers +// helper to parse and validate positive numbers function parsePositiveNumber( value: string | undefined, defaultValue: number, @@ -27,7 +27,7 @@ function parsePositiveNumber( return parsed } -// Configuration for sign-up attempts (prevent spam) +// sign-up attempts configuration (prevent spam) const SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW = parsePositiveNumber( process.env.SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW as string | undefined, 10, @@ -39,7 +39,7 @@ const SIGN_UP_ATTEMPTS_WINDOW_HOURS = parsePositiveNumber( 'SIGN_UP_ATTEMPTS_WINDOW_HOURS' ) -// Configuration for actual sign-ups (limit account creation) +// actual sign-ups configuration (limit account creation) const SIGN_UP_LIMIT_PER_WINDOW = parsePositiveNumber( process.env.SIGN_UP_LIMIT_PER_WINDOW as string | undefined, 1, @@ -51,16 +51,10 @@ const SIGN_UP_WINDOW_HOURS = parsePositiveNumber( 'SIGN_UP_WINDOW_HOURS' ) -// Convert to Duration format +// convert to duration format const SIGN_UP_ATTEMPTS_WINDOW: Duration = `${SIGN_UP_ATTEMPTS_WINDOW_HOURS}h` const SIGN_UP_WINDOW: Duration = `${SIGN_UP_WINDOW_HOURS}h` -/** - * Check if an identifier (IP address) has exceeded the sign-up attempts limit - * This prevents spam/abuse of the sign-up endpoint - * @param identifier - IP address to check rate limiting for - * @returns Promise - true if rate limited, false if allowed - */ export async function isSignUpAttemptRateLimited( identifier: string ): Promise { @@ -71,45 +65,21 @@ export async function isSignUpAttemptRateLimited( SIGN_UP_ATTEMPTS_WINDOW ) - // If rate limiting is not configured, allow the request if (!result) { return false } - const isRateLimited = !result.success - - if (isRateLimited) { - l.debug({ - key: 'sign_up_attempt_rate_limit:blocked', - context: { - identifier, - limit: result.limit, - remaining: result.remaining, - reset: result.reset, - }, - }) - } - - return isRateLimited + return !result.success } catch (error) { l.error({ key: 'sign_up_attempt_rate_limit:check_error', error: serializeError(error), - context: { - identifier, - }, }) // on error, allow the request to proceed return false } } -/** - * Check if an identifier (IP address) has exceeded the actual sign-up limit - * This limits the number of confirmed accounts created per time window - * @param identifier - IP address to check rate limiting for - * @returns Promise - true if rate limited, false if allowed - */ export async function isSignUpRateLimited( identifier: string ): Promise { @@ -120,42 +90,21 @@ export async function isSignUpRateLimited( SIGN_UP_WINDOW ) - // If rate limiting is not configured, allow the request if (!result) { return false } - const isRateLimited = !result.success - - if (isRateLimited) { - l.debug({ - key: 'sign_up_rate_limit:blocked', - context: { - identifier, - limit: result.limit, - remaining: result.remaining, - reset: result.reset, - }, - }) - } - - return isRateLimited + return !result.success } catch (error) { l.error({ key: 'sign_up_rate_limit:check_error', error: serializeError(error), - context: { - identifier, - }, }) // on error, allow the request to proceed return false } } -/** - * Log rate limit configuration on startup (for debugging) - */ export function logRateLimitConfiguration() { l.info({ key: 'rate_limit_configuration', From ada193f0fcd8c0d1c870f8c5286f67a3a5fa2838 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Thu, 25 Sep 2025 15:50:50 +0200 Subject: [PATCH 11/20] refactor: Simplify rate limiting logic and remove deprecated configurations --- .env.example | 2 - src/app/api/auth/confirm/route.ts | 39 +++++++------ src/lib/env.ts | 2 - src/lib/utils/ip-extraction.ts | 11 +--- src/lib/utils/ratelimit.ts | 36 +++++++++++- src/server/auth/RATE_LIMITING.md | 93 ------------------------------- src/server/auth/auth-actions.ts | 60 ++++++++++++-------- src/server/auth/rate-limiting.ts | 47 +++------------- 8 files changed, 104 insertions(+), 186 deletions(-) delete mode 100644 src/server/auth/RATE_LIMITING.md diff --git a/.env.example b/.env.example index 442ed79a0..b0b2b10a2 100644 --- a/.env.example +++ b/.env.example @@ -31,8 +31,6 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### Auth Rate Limiting # ENABLE_SIGN_UP_RATE_LIMITING=1 -# SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW=10 -# SIGN_UP_ATTEMPTS_WINDOW_HOURS=6 # SIGN_UP_LIMIT_PER_WINDOW=1 # SIGN_UP_WINDOW_HOURS=24 diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 67b195f5b..619c92652 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -3,8 +3,8 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l } from '@/lib/clients/logger/logger' import { createClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' -import { extractClientIp, isDevelopmentIp } from '@/lib/utils/ip-extraction' -import { isSignUpRateLimited } from '@/server/auth/rate-limiting' +import { extractClientIp } from '@/lib/utils/ip-extraction' +import { checkSignUpRateLimit } from '@/server/auth/rate-limiting' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' import { serializeError } from 'serialize-error' @@ -97,29 +97,34 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(next) - // check rate limit for email confirmations - if (ENABLE_SIGN_UP_RATE_LIMITING && supabaseType === 'email') { - const ip = extractClientIp(request.headers) - - // log error if no ip headers found in production - if (isDevelopmentIp(ip) && process.env.NODE_ENV === 'production') { - l.error({ - key: 'sign_up_confirm:no_ip_headers', - context: { - message: 'no ip headers found in production', - }, - }) - } + // RATE LIMITING - const isRateLimited = await isSignUpRateLimited(ip) + if ( + ENABLE_SIGN_UP_RATE_LIMITING && + process.env.NODE_ENV === 'production' && + supabaseType === 'email' + ) { + const ip = extractClientIp(request.headers) - if (isRateLimited) { + if (ip && (await checkSignUpRateLimit(ip))) { return encodedRedirect( 'error', dashboardSignInUrl.toString(), 'Too many sign-ups for now. Please try again later.' ) } + + if (!ip) { + l.warn( + { + key: 'sign_up_confirm:no_ip_headers', + context: { + message: 'no ip headers found in production', + }, + }, + 'Tried to rate limit, but no ip headers were found in production.' + ) + } } const supabase = await createClient() diff --git a/src/lib/env.ts b/src/lib/env.ts index b6664cd46..a5307229c 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -24,8 +24,6 @@ export const serverSchema = z.object({ ZEROBOUNCE_API_KEY: z.string().optional(), ENABLE_SIGN_UP_RATE_LIMITING: NumericBoolean.optional(), - SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW: StringPositiveNumber.optional(), - SIGN_UP_ATTEMPTS_WINDOW_HOURS: StringPositiveNumber.optional(), SIGN_UP_LIMIT_PER_WINDOW: StringPositiveNumber.optional(), SIGN_UP_WINDOW_HOURS: StringPositiveNumber.optional(), diff --git a/src/lib/utils/ip-extraction.ts b/src/lib/utils/ip-extraction.ts index 9c69889a8..d4ad61102 100644 --- a/src/lib/utils/ip-extraction.ts +++ b/src/lib/utils/ip-extraction.ts @@ -1,6 +1,4 @@ -export function extractClientIp(headers: { - get(name: string): string | null -}): string { +export function extractClientIp(headers: Headers): string | null { const xForwardedFor = headers.get('x-forwarded-for') const cfConnectingIp = headers.get('cf-connecting-ip') const xRealIp = headers.get('x-real-ip') @@ -21,10 +19,5 @@ export function extractClientIp(headers: { return xRealIp } - // fallback for development - return 'development-no-ip' + return null } - -export function isDevelopmentIp(ip: string): boolean { - return ip === 'development-no-ip' -} \ No newline at end of file diff --git a/src/lib/utils/ratelimit.ts b/src/lib/utils/ratelimit.ts index 4dcde1bdc..ddec93209 100644 --- a/src/lib/utils/ratelimit.ts +++ b/src/lib/utils/ratelimit.ts @@ -16,7 +16,7 @@ export interface RateLimitResult { * @param window - Time window for the rate limit (e.g., '1h', '24h', '1d') * @returns RateLimitResult with success status and rate limit metadata */ -export default async function ratelimit( +export async function applyRateLimit( key: string, maxRequests: number, window: Duration @@ -41,3 +41,37 @@ export default async function ratelimit( // if rate limiting is not configured, allow all requests return null } + +/** + * Check rate limit status without incrementing the counter + * @param key - Unique identifier for the rate limit (e.g., user ID, IP address) + * @param maxRequests - Maximum number of requests allowed in the window + * @param window - Time window for the rate limit (e.g., '1h', '24h', '1d') + * @returns RateLimitResult with current status, or null if rate limiting disabled + */ +export async function checkRateLimit( + key: string, + maxRequests: number, + window: Duration +): Promise { + // only apply rate limiting if kv is configured + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(maxRequests, window), + }) + + // getRemaining() to check without incrementing + const result = await ratelimit.getRemaining(key) + + return { + success: result.remaining > 0, + limit: maxRequests, + reset: result.reset, + remaining: result.remaining, + } + } + + // if rate limiting is not configured, allow all requests + return null +} diff --git a/src/server/auth/RATE_LIMITING.md b/src/server/auth/RATE_LIMITING.md deleted file mode 100644 index 992f45700..000000000 --- a/src/server/auth/RATE_LIMITING.md +++ /dev/null @@ -1,93 +0,0 @@ -# Rate Limiting Documentation - -This system implements two separate rate limiters for the sign-up flow to prevent different types of abuse. - -## Important: Development Environment Support - -The rate limiting system now works seamlessly in development environments without proxy headers. When IP headers are missing, the system falls back to a development identifier that still allows rate limiting to function properly. - -## 1. Sign-Up Attempts Rate Limiter - -**Purpose**: Prevents spam/abuse of the sign-up endpoint by limiting how many times someone can attempt to create an account. - -**When Applied**: During the initial sign-up request (before email confirmation) - -**Configuration**: -- `SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW` - Maximum number of sign-up attempts allowed (default: 10, must be positive) -- `SIGN_UP_ATTEMPTS_WINDOW_HOURS` - Time window in hours for attempt limiting (default: 1, must be positive) - -**Example**: With defaults, allows 10 sign-up attempts per hour per IP address. - -## 2. Actual Sign-Ups Rate Limiter - -**Purpose**: Limits the number of confirmed accounts that can be created to prevent mass account creation. - -**When Applied**: During email confirmation (when the user clicks the confirmation link) - -**Configuration**: -- `SIGN_UP_LIMIT_PER_WINDOW` - Maximum number of confirmed sign-ups allowed (default: 1, must be positive) -- `SIGN_UP_WINDOW_HOURS` - Time window in hours for sign-up limiting (default: 24, must be positive) - -**Example**: With defaults, allows 1 confirmed account creation per day per IP address. - -## Environment Variables - -Add these to your `.env` file: - -```bash -# Enable rate limiting (required along with KV configuration) -ENABLE_SIGN_UP_RATE_LIMITING=1 - -# Redis/KV configuration (required for rate limiting) -KV_REST_API_URL=your_kv_url -KV_REST_API_TOKEN=your_kv_token - -# Sign-up attempts rate limiting -SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW=10 # Max attempts per window (must be positive) -SIGN_UP_ATTEMPTS_WINDOW_HOURS=1 # Window size in hours (must be positive) - -# Actual sign-ups rate limiting -SIGN_UP_LIMIT_PER_WINDOW=1 # Max confirmed sign-ups per window (must be positive) -SIGN_UP_WINDOW_HOURS=24 # Window size in hours (must be positive) -``` - -## How It Works - -1. **User attempts to sign up** → Check against attempts rate limiter - - If rate limited: Return "Too many sign-up attempts. Please try again later." - - If allowed: Proceed with sign-up - -2. **User confirms email** → Check against actual sign-ups rate limiter - - If rate limited: Return "Too many sign-ups for now. Please try again later." - - If allowed: Complete account creation - -## Key Prefixes - -The rate limiters use different Redis key prefixes to track separate counters: -- Sign-up attempts: `signup-attempt:{ip}` -- Actual sign-ups: `signup:{ip}` - -This ensures the two rate limiters operate independently. - -## IP Address Handling - -The system properly handles various IP header formats: - -1. **x-forwarded-for**: Parsed to extract the first IP from comma-separated list (client IP) -2. **cf-connecting-ip**: Used directly (Cloudflare header) -3. **x-real-ip**: Used directly (generic proxy header) -4. **Development fallback**: Uses 'development-no-ip' when no headers are present - -This ensures the rate limiting works in: -- Production environments with proxies/CDNs -- Development environments without proxy headers -- Local testing scenarios - -## Benefits - -- **Better User Experience**: Users can retry if they make mistakes (e.g., wrong password format) -- **Enhanced Security**: Prevents both spam attempts and mass account creation -- **Flexible Configuration**: Each rate limiter can be tuned independently -- **Clear Separation**: Different Redis keys prevent interference between the two systems -- **Development Support**: Works seamlessly in local development without proxy configuration -- **Proper IP Parsing**: Correctly handles multi-IP headers from proxy chains diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index aa323b551..268aadc6e 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -9,7 +9,7 @@ import { createClient } from '@/lib/clients/supabase/server' import { relativeUrlSchema } from '@/lib/schemas/url' import { returnServerError } from '@/lib/utils/action' import { encodedRedirect } from '@/lib/utils/auth' -import { extractClientIp, isDevelopmentIp } from '@/lib/utils/ip-extraction' +import { extractClientIp } from '@/lib/utils/ip-extraction' import { shouldWarnAboutAlternateEmail, validateEmail, @@ -20,7 +20,7 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' -import { isSignUpAttemptRateLimited } from './rate-limiting' +import { applySignUpRateLimit, checkSignUpRateLimit } from './rate-limiting' export const signInWithOAuthAction = actionClient .schema( @@ -81,7 +81,11 @@ export const signUpAction = actionClient .metadata({ actionName: 'signUp' }) .action(async ({ parsedInput: { email, password, returnTo = '' } }) => { const supabase = await createClient() - const origin = (await headers()).get('origin') || '' + const headersStore = await headers() + + const origin = headersStore.get('origin') || '' + + // EMAIL VALIDATION // basic security check, that password does not equal e-mail if (password && email && password.toLowerCase() === email.toLowerCase()) { @@ -106,32 +110,32 @@ export const signUpAction = actionClient } } - const headersStore = await headers() + // RATE LIMITING + const ip = extractClientIp(headersStore) - // log error if no ip headers found - if ( - isDevelopmentIp(ip) && - ENABLE_SIGN_UP_RATE_LIMITING && - process.env.NODE_ENV === 'production' - ) { - l.error({ - key: 'sign_up_attempt:no_ip_headers', - context: { - message: 'no ip headers found in production', - }, - }) - } + if (ENABLE_SIGN_UP_RATE_LIMITING && process.env.NODE_ENV === 'production') { + if (ip && (await checkSignUpRateLimit(ip))) { + return returnServerError( + 'Too many sign-up attempts. Please try again later.' + ) + } - if ( - ENABLE_SIGN_UP_RATE_LIMITING && - (await isSignUpAttemptRateLimited(ip)) - ) { - return returnServerError( - 'Too many sign-up attempts. Please try again later.' - ) + if (!ip) { + l.warn( + { + key: 'sign_up_rate_limit:no_ip_headers', + context: { + message: 'no ip headers found in production', + }, + }, + 'Tried to rate limit, but no ip headers were found in production.' + ) + } } + // SIGN UP + const { error } = await supabase.auth.signUp({ email, password, @@ -159,6 +163,14 @@ export const signUpAction = actionClient throw error } } + + if ( + ENABLE_SIGN_UP_RATE_LIMITING && + process.env.NODE_ENV === 'production' && + ip + ) { + await applySignUpRateLimit(ip) + } }) export const signInAction = actionClient diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index a894e1b50..b90b6d401 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -1,6 +1,6 @@ import { l } from '@/lib/clients/logger/logger' import { Duration } from '@/lib/utils/duration' -import ratelimit from '@/lib/utils/ratelimit' +import { applyRateLimit, checkRateLimit } from '@/lib/utils/ratelimit' import { serializeError } from 'serialize-error' // helper to parse and validate positive numbers @@ -27,18 +27,6 @@ function parsePositiveNumber( return parsed } -// sign-up attempts configuration (prevent spam) -const SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW = parsePositiveNumber( - process.env.SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW as string | undefined, - 10, - 'SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW' -) -const SIGN_UP_ATTEMPTS_WINDOW_HOURS = parsePositiveNumber( - process.env.SIGN_UP_ATTEMPTS_WINDOW_HOURS as string | undefined, - 1, - 'SIGN_UP_ATTEMPTS_WINDOW_HOURS' -) - // actual sign-ups configuration (limit account creation) const SIGN_UP_LIMIT_PER_WINDOW = parsePositiveNumber( process.env.SIGN_UP_LIMIT_PER_WINDOW as string | undefined, @@ -52,17 +40,16 @@ const SIGN_UP_WINDOW_HOURS = parsePositiveNumber( ) // convert to duration format -const SIGN_UP_ATTEMPTS_WINDOW: Duration = `${SIGN_UP_ATTEMPTS_WINDOW_HOURS}h` const SIGN_UP_WINDOW: Duration = `${SIGN_UP_WINDOW_HOURS}h` -export async function isSignUpAttemptRateLimited( +export async function applySignUpRateLimit( identifier: string ): Promise { try { - const result = await ratelimit( - `signup-attempt:${identifier}`, - SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW, - SIGN_UP_ATTEMPTS_WINDOW + const result = await applyRateLimit( + `signup:${identifier}`, + SIGN_UP_LIMIT_PER_WINDOW, + SIGN_UP_WINDOW ) if (!result) { @@ -72,7 +59,7 @@ export async function isSignUpAttemptRateLimited( return !result.success } catch (error) { l.error({ - key: 'sign_up_attempt_rate_limit:check_error', + key: 'sign_up_rate_limit:check_error', error: serializeError(error), }) // on error, allow the request to proceed @@ -80,11 +67,11 @@ export async function isSignUpAttemptRateLimited( } } -export async function isSignUpRateLimited( +export async function checkSignUpRateLimit( identifier: string ): Promise { try { - const result = await ratelimit( + const result = await checkRateLimit( `signup:${identifier}`, SIGN_UP_LIMIT_PER_WINDOW, SIGN_UP_WINDOW @@ -104,19 +91,3 @@ export async function isSignUpRateLimited( return false } } - -export function logRateLimitConfiguration() { - l.info({ - key: 'rate_limit_configuration', - context: { - sign_up_attempts: { - limit: SIGN_UP_ATTEMPTS_LIMIT_PER_WINDOW, - window: SIGN_UP_ATTEMPTS_WINDOW, - }, - sign_ups: { - limit: SIGN_UP_LIMIT_PER_WINDOW, - window: SIGN_UP_WINDOW, - }, - }, - }) -} From a950aa5a301ec0e1fbe0d1083e6e8cf3c1591b79 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Thu, 25 Sep 2025 16:07:36 +0200 Subject: [PATCH 12/20] refactor: Remove rate limiting logic from sign-up confirmation route --- src/app/api/auth/confirm/route.ts | 33 ------------------------------- 1 file changed, 33 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 619c92652..91a95a26d 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -1,10 +1,7 @@ -import { ENABLE_SIGN_UP_RATE_LIMITING } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l } from '@/lib/clients/logger/logger' import { createClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' -import { extractClientIp } from '@/lib/utils/ip-extraction' -import { checkSignUpRateLimit } from '@/server/auth/rate-limiting' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' import { serializeError } from 'serialize-error' @@ -97,36 +94,6 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(next) - // RATE LIMITING - - if ( - ENABLE_SIGN_UP_RATE_LIMITING && - process.env.NODE_ENV === 'production' && - supabaseType === 'email' - ) { - const ip = extractClientIp(request.headers) - - if (ip && (await checkSignUpRateLimit(ip))) { - return encodedRedirect( - 'error', - dashboardSignInUrl.toString(), - 'Too many sign-ups for now. Please try again later.' - ) - } - - if (!ip) { - l.warn( - { - key: 'sign_up_confirm:no_ip_headers', - context: { - message: 'no ip headers found in production', - }, - }, - 'Tried to rate limit, but no ip headers were found in production.' - ) - } - } - const supabase = await createClient() const { error, data } = await supabase.auth.verifyOtp({ From b9adb0f0b53ce8c8aa39d5c81a80fbfc34867d1d Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 26 Sep 2025 11:37:18 +0200 Subject: [PATCH 13/20] refactor: rate limit handling to simplify --- bun.lock | 5 ++ package.json | 1 + src/lib/utils/ip-extraction.ts | 23 -------- src/lib/utils/ratelimit.ts | 77 ------------------------- src/server/auth/auth-actions.ts | 10 ++-- src/server/auth/rate-limiting.ts | 99 +++++++++----------------------- tsconfig.json | 22 +++++-- 7 files changed, 54 insertions(+), 183 deletions(-) delete mode 100644 src/lib/utils/ip-extraction.ts delete mode 100644 src/lib/utils/ratelimit.ts diff --git a/bun.lock b/bun.lock index ff9c3e44d..8fcacc478 100644 --- a/bun.lock +++ b/bun.lock @@ -49,6 +49,7 @@ "@types/micromatch": "^4.0.9", "@upstash/ratelimit": "^2.0.6", "@vercel/analytics": "^1.5.0", + "@vercel/functions": "^3.1.0", "@vercel/kv": "^3.0.0", "@vercel/otel": "^1.13.0", "@vercel/speed-insights": "^1.2.0", @@ -1171,8 +1172,12 @@ "@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="], + "@vercel/functions": ["@vercel/functions@3.1.0", "", { "dependencies": { "@vercel/oidc": "3.0.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-V+p8dO+sg1VjiJJUO5rYPp1KG17SzDcR74OWwW7Euyde6L8U5wuTMe9QfEOfLTiWPUPzN1MXZvLcYxqSYhKc4Q=="], + "@vercel/kv": ["@vercel/kv@3.0.0", "", { "dependencies": { "@upstash/redis": "^1.34.0" } }, "sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg=="], + "@vercel/oidc": ["@vercel/oidc@3.0.0", "", { "dependencies": { "@types/ms": "2.1.0", "ms": "2.1.3" } }, "sha512-XOoUcf/1VfGArUAfq0ELxk6TD7l4jGcrOsWjQibj4wYM74uNihzZ9gA46ywWegoqKWWdph4y5CKxGI9823deoA=="], + "@vercel/otel": ["@vercel/otel@1.13.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.7.0 <2.0.0", "@opentelemetry/api-logs": ">=0.46.0 <0.200.0", "@opentelemetry/instrumentation": ">=0.46.0 <0.200.0", "@opentelemetry/resources": ">=1.19.0 <2.0.0", "@opentelemetry/sdk-logs": ">=0.46.0 <0.200.0", "@opentelemetry/sdk-metrics": ">=1.19.0 <2.0.0", "@opentelemetry/sdk-trace-base": ">=1.19.0 <2.0.0" } }, "sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q=="], "@vercel/speed-insights": ["@vercel/speed-insights@1.2.0", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw=="], diff --git a/package.json b/package.json index 925d32938..3cd746c09 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@types/micromatch": "^4.0.9", "@upstash/ratelimit": "^2.0.6", "@vercel/analytics": "^1.5.0", + "@vercel/functions": "^3.1.0", "@vercel/kv": "^3.0.0", "@vercel/otel": "^1.13.0", "@vercel/speed-insights": "^1.2.0", diff --git a/src/lib/utils/ip-extraction.ts b/src/lib/utils/ip-extraction.ts deleted file mode 100644 index d4ad61102..000000000 --- a/src/lib/utils/ip-extraction.ts +++ /dev/null @@ -1,23 +0,0 @@ -export function extractClientIp(headers: Headers): string | null { - const xForwardedFor = headers.get('x-forwarded-for') - const cfConnectingIp = headers.get('cf-connecting-ip') - const xRealIp = headers.get('x-real-ip') - - // parse x-forwarded-for: "client, proxy1, proxy2" -> extract client ip - if (xForwardedFor) { - const ips = xForwardedFor.split(',').map((ip) => ip.trim()) - if (ips[0]) { - return ips[0] - } - } - - if (cfConnectingIp) { - return cfConnectingIp - } - - if (xRealIp) { - return xRealIp - } - - return null -} diff --git a/src/lib/utils/ratelimit.ts b/src/lib/utils/ratelimit.ts deleted file mode 100644 index ddec93209..000000000 --- a/src/lib/utils/ratelimit.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { kv } from '@/lib/clients/kv' -import { Ratelimit } from '@upstash/ratelimit' -import { Duration } from './duration' - -export interface RateLimitResult { - success: boolean - limit: number - reset: number - remaining: number -} - -/** - * Apply rate limiting to a key using @upstash/ratelimit - * @param key - Unique identifier for the rate limit (e.g., user ID, IP address) - * @param maxRequests - Maximum number of requests allowed in the window - * @param window - Time window for the rate limit (e.g., '1h', '24h', '1d') - * @returns RateLimitResult with success status and rate limit metadata - */ -export async function applyRateLimit( - key: string, - maxRequests: number, - window: Duration -): Promise { - // only apply rate limiting if kv is configured - if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { - const ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(maxRequests, window), - }) - - const result = await ratelimit.limit(key) - - return { - success: result.success, - limit: result.limit, - reset: result.reset, - remaining: result.remaining, - } - } - - // if rate limiting is not configured, allow all requests - return null -} - -/** - * Check rate limit status without incrementing the counter - * @param key - Unique identifier for the rate limit (e.g., user ID, IP address) - * @param maxRequests - Maximum number of requests allowed in the window - * @param window - Time window for the rate limit (e.g., '1h', '24h', '1d') - * @returns RateLimitResult with current status, or null if rate limiting disabled - */ -export async function checkRateLimit( - key: string, - maxRequests: number, - window: Duration -): Promise { - // only apply rate limiting if kv is configured - if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { - const ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(maxRequests, window), - }) - - // getRemaining() to check without incrementing - const result = await ratelimit.getRemaining(key) - - return { - success: result.remaining > 0, - limit: maxRequests, - reset: result.reset, - remaining: result.remaining, - } - } - - // if rate limiting is not configured, allow all requests - return null -} diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 268aadc6e..757d7427f 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -9,18 +9,18 @@ import { createClient } from '@/lib/clients/supabase/server' import { relativeUrlSchema } from '@/lib/schemas/url' import { returnServerError } from '@/lib/utils/action' import { encodedRedirect } from '@/lib/utils/auth' -import { extractClientIp } from '@/lib/utils/ip-extraction' import { shouldWarnAboutAlternateEmail, validateEmail, } from '@/server/auth/validate-email' import { Provider } from '@supabase/supabase-js' +import { ipAddress } from '@vercel/functions' import { returnValidationErrors } from 'next-safe-action' import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' -import { applySignUpRateLimit, checkSignUpRateLimit } from './rate-limiting' +import { incrementSignUpRateLimit, isSignUpRateLimited } from './rate-limiting' export const signInWithOAuthAction = actionClient .schema( @@ -112,10 +112,10 @@ export const signUpAction = actionClient // RATE LIMITING - const ip = extractClientIp(headersStore) + const ip = ipAddress(headersStore) if (ENABLE_SIGN_UP_RATE_LIMITING && process.env.NODE_ENV === 'production') { - if (ip && (await checkSignUpRateLimit(ip))) { + if (ip && (await isSignUpRateLimited(ip))) { return returnServerError( 'Too many sign-up attempts. Please try again later.' ) @@ -169,7 +169,7 @@ export const signUpAction = actionClient process.env.NODE_ENV === 'production' && ip ) { - await applySignUpRateLimit(ip) + await incrementSignUpRateLimit(ip) } }) diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/rate-limiting.ts index b90b6d401..b797f831f 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/rate-limiting.ts @@ -1,93 +1,46 @@ +import 'server-cli-only' + import { l } from '@/lib/clients/logger/logger' import { Duration } from '@/lib/utils/duration' -import { applyRateLimit, checkRateLimit } from '@/lib/utils/ratelimit' -import { serializeError } from 'serialize-error' - -// helper to parse and validate positive numbers -function parsePositiveNumber( - value: string | undefined, - defaultValue: number, - name: string -): number { - if (!value) return defaultValue +import { Ratelimit } from '@upstash/ratelimit' +import { kv } from '@vercel/kv' - const parsed = Number(value) - if (isNaN(parsed) || parsed <= 0) { - l.warn({ - key: 'rate_limit_config:invalid_value', - context: { - variable: name, - value, - defaultUsed: defaultValue, - }, - }) - return defaultValue - } - - return parsed -} - -// actual sign-ups configuration (limit account creation) -const SIGN_UP_LIMIT_PER_WINDOW = parsePositiveNumber( - process.env.SIGN_UP_LIMIT_PER_WINDOW as string | undefined, - 1, - 'SIGN_UP_LIMIT_PER_WINDOW' -) -const SIGN_UP_WINDOW_HOURS = parsePositiveNumber( - process.env.SIGN_UP_WINDOW_HOURS as string | undefined, - 24, - 'SIGN_UP_WINDOW_HOURS' +const SIGN_UP_LIMIT_PER_WINDOW = parseInt( + process.env.SIGN_UP_LIMIT_PER_WINDOW || '1' ) +const SIGN_UP_WINDOW_HOURS = parseInt(process.env.SIGN_UP_WINDOW_HOURS || '24') -// convert to duration format const SIGN_UP_WINDOW: Duration = `${SIGN_UP_WINDOW_HOURS}h` -export async function applySignUpRateLimit( +const _ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(SIGN_UP_LIMIT_PER_WINDOW, SIGN_UP_WINDOW), +}) + +export async function incrementSignUpRateLimit( identifier: string ): Promise { - try { - const result = await applyRateLimit( - `signup:${identifier}`, - SIGN_UP_LIMIT_PER_WINDOW, - SIGN_UP_WINDOW - ) + const result = await _ratelimit.limit(identifier) - if (!result) { - return false - } - - return !result.success - } catch (error) { + if (!result.success) { l.error({ - key: 'sign_up_rate_limit:check_error', - error: serializeError(error), + key: 'sign_up_rate_limit_increment:limit_error', + context: { + identifier, + result, + }, }) - // on error, allow the request to proceed + return false } + + return result.remaining === 0 } -export async function checkSignUpRateLimit( +export async function isSignUpRateLimited( identifier: string ): Promise { - try { - const result = await checkRateLimit( - `signup:${identifier}`, - SIGN_UP_LIMIT_PER_WINDOW, - SIGN_UP_WINDOW - ) + const result = await _ratelimit.getRemaining(identifier) - if (!result) { - return false - } - - return !result.success - } catch (error) { - l.error({ - key: 'sign_up_rate_limit:check_error', - error: serializeError(error), - }) - // on error, allow the request to proceed - return false - } + return result.remaining === 0 } diff --git a/tsconfig.json b/tsconfig.json index db7d5f71a..1f5c78aa7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2020", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -12,7 +16,7 @@ "moduleResolution": "bundler", "noUncheckedIndexedAccess": true, "resolveJsonModule": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { @@ -20,10 +24,18 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] }, "isolatedModules": true }, - "include": ["next-env.d.ts", "src", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "src", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } From 3f7d1d435b0c6898b44f62a2d70a8a039589158a Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 26 Sep 2025 11:44:21 +0200 Subject: [PATCH 14/20] chore: clean up --- src/lib/utils/duration.ts | 40 ------------------- src/server/auth/auth-actions.ts | 2 +- .../auth/{rate-limiting.ts => ratelimit.ts} | 3 +- 3 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 src/lib/utils/duration.ts rename src/server/auth/{rate-limiting.ts => ratelimit.ts} (91%) diff --git a/src/lib/utils/duration.ts b/src/lib/utils/duration.ts deleted file mode 100644 index 76b9c2c0d..000000000 --- a/src/lib/utils/duration.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Taken from https://github.com/upstash/ratelimit/blob/main/src/duration.ts - -type Unit = 'ms' | 's' | 'm' | 'h' | 'd' -export type Duration = `${number} ${Unit}` | `${number}${Unit}` - -/** - * Convert a human readable duration to milliseconds - */ -export function ms(d: Duration): number { - const match = d.match(/^(\d+)\s?(ms|s|m|h|d)$/) - - if (!match || !match[1] || !match[2]) { - throw new Error(`Unable to parse window size: ${d}`) - } - - const time = Number.parseInt(match[1]) - const unit = match[2] as Unit - - switch (unit) { - case 'ms': { - return time - } - case 's': { - return time * 1000 - } - case 'm': { - return time * 1000 * 60 - } - case 'h': { - return time * 1000 * 60 * 60 - } - case 'd': { - return time * 1000 * 60 * 60 * 24 - } - - default: { - throw new Error(`Unable to parse window size: ${d}`) - } - } -} diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 757d7427f..815e1251b 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -20,7 +20,7 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' -import { incrementSignUpRateLimit, isSignUpRateLimited } from './rate-limiting' +import { incrementSignUpRateLimit, isSignUpRateLimited } from './ratelimit' export const signInWithOAuthAction = actionClient .schema( diff --git a/src/server/auth/rate-limiting.ts b/src/server/auth/ratelimit.ts similarity index 91% rename from src/server/auth/rate-limiting.ts rename to src/server/auth/ratelimit.ts index b797f831f..325dd7e8f 100644 --- a/src/server/auth/rate-limiting.ts +++ b/src/server/auth/ratelimit.ts @@ -1,8 +1,7 @@ import 'server-cli-only' import { l } from '@/lib/clients/logger/logger' -import { Duration } from '@/lib/utils/duration' -import { Ratelimit } from '@upstash/ratelimit' +import { Duration, Ratelimit } from '@upstash/ratelimit' import { kv } from '@vercel/kv' const SIGN_UP_LIMIT_PER_WINDOW = parseInt( From 9fbb68b6282ab36e47155c15f116d57334d486d0 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 26 Sep 2025 12:48:20 +0200 Subject: [PATCH 15/20] wip: fix race condition --- src/configs/keys.ts | 1 + src/server/auth/auth-actions.ts | 55 ++++++++++++++++++--------------- src/server/auth/ratelimit.ts | 42 ++++++++++++------------- 3 files changed, 50 insertions(+), 48 deletions(-) diff --git a/src/configs/keys.ts b/src/configs/keys.ts index ce05a8e0d..2e70eefdf 100644 --- a/src/configs/keys.ts +++ b/src/configs/keys.ts @@ -19,6 +19,7 @@ export const KV_KEYS = { TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`, TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`, WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`, + RATE_LIMIT_SIGN_UP: (identifier: string) => `ratelimit:sign-up:${identifier}`, } /* diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 815e1251b..47c5fe2ba 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -20,7 +20,7 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' -import { incrementSignUpRateLimit, isSignUpRateLimited } from './ratelimit' +import { resetSignUpRateLimit, signUpRateLimit } from './ratelimit' export const signInWithOAuthAction = actionClient .schema( @@ -114,24 +114,31 @@ export const signUpAction = actionClient const ip = ipAddress(headersStore) - if (ENABLE_SIGN_UP_RATE_LIMITING && process.env.NODE_ENV === 'production') { - if (ip && (await isSignUpRateLimited(ip))) { - return returnServerError( - 'Too many sign-up attempts. Please try again later.' - ) - } + const shouldRateLimit = + ENABLE_SIGN_UP_RATE_LIMITING && + process.env.NODE_ENV === 'production' && + ip - if (!ip) { - l.warn( - { - key: 'sign_up_rate_limit:no_ip_headers', - context: { - message: 'no ip headers found in production', - }, + if ( + ENABLE_SIGN_UP_RATE_LIMITING && + process.env.NODE_ENV === 'production' && + !ip + ) { + l.warn( + { + key: 'sign_up_rate_limit:no_ip_headers', + context: { + message: 'no ip headers found in production', }, - 'Tried to rate limit, but no ip headers were found in production.' - ) - } + }, + 'Tried to rate limit, but no ip headers were found in production.' + ) + } + + if (shouldRateLimit && (await signUpRateLimit(ip))) { + return returnServerError( + 'Too many sign-up attempts. Please try again later.' + ) } // SIGN UP @@ -150,6 +157,12 @@ export const signUpAction = actionClient }) if (error) { + // we reset the sign up rate limit on failure, + // since no account got registered in the end. + if (shouldRateLimit) { + resetSignUpRateLimit(ip) + } + switch (error.code) { case 'email_exists': return returnServerError(USER_MESSAGES.emailInUse.message) @@ -163,14 +176,6 @@ export const signUpAction = actionClient throw error } } - - if ( - ENABLE_SIGN_UP_RATE_LIMITING && - process.env.NODE_ENV === 'production' && - ip - ) { - await incrementSignUpRateLimit(ip) - } }) export const signInAction = actionClient diff --git a/src/server/auth/ratelimit.ts b/src/server/auth/ratelimit.ts index 325dd7e8f..cd082562f 100644 --- a/src/server/auth/ratelimit.ts +++ b/src/server/auth/ratelimit.ts @@ -1,6 +1,5 @@ import 'server-cli-only' -import { l } from '@/lib/clients/logger/logger' import { Duration, Ratelimit } from '@upstash/ratelimit' import { kv } from '@vercel/kv' @@ -16,30 +15,27 @@ const _ratelimit = new Ratelimit({ limiter: Ratelimit.slidingWindow(SIGN_UP_LIMIT_PER_WINDOW, SIGN_UP_WINDOW), }) -export async function incrementSignUpRateLimit( - identifier: string -): Promise { +/** + * Checks if the sign-up rate limit has been reached for the given identifier. + * + * @param identifier - The unique identifier (e.g., IP address, user ID) to check rate limit for + * @returns Promise - Returns true if the rate limit has been reached (no more attempts allowed), + * false if more attempts are available or if there was an error checking the limit + */ +export async function signUpRateLimit(identifier: string): Promise { const result = await _ratelimit.limit(identifier) - if (!result.success) { - l.error({ - key: 'sign_up_rate_limit_increment:limit_error', - context: { - identifier, - result, - }, - }) - - return false - } - - return result.remaining === 0 + // we return: + // - true (is rate limited) + // - false (is not rate limited) + return !result.success } -export async function isSignUpRateLimited( - identifier: string -): Promise { - const result = await _ratelimit.getRemaining(identifier) - - return result.remaining === 0 +/** + * Resets the rate limit counter for the given identifier, allowing them to make new attempts. + * + * @param identifier - The unique identifier whose rate limit should be reset + */ +export async function resetSignUpRateLimit(identifier: string) { + await _ratelimit.resetUsedTokens(identifier) } From 2ac2a5c7cdbb2c30719c73ead7ee3b30b70dd04b Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Tue, 7 Oct 2025 13:10:06 +0200 Subject: [PATCH 16/20] remove: rate limit lib in favor of incr/decr --- bun.lock | 5 ---- package.json | 1 - src/server/auth/auth-actions.ts | 14 +++++---- src/server/auth/ratelimit.ts | 53 +++++++++++++++++++-------------- 4 files changed, 40 insertions(+), 33 deletions(-) diff --git a/bun.lock b/bun.lock index 8fcacc478..c50c5dd6a 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,6 @@ "@theguild/remark-mermaid": "^0.2.0", "@types/mdx": "^2.0.13", "@types/micromatch": "^4.0.9", - "@upstash/ratelimit": "^2.0.6", "@vercel/analytics": "^1.5.0", "@vercel/functions": "^3.1.0", "@vercel/kv": "^3.0.0", @@ -1164,10 +1163,6 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], - "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], - - "@upstash/ratelimit": ["@upstash/ratelimit@2.0.6", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg=="], - "@upstash/redis": ["@upstash/redis@1.35.3", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w=="], "@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="], diff --git a/package.json b/package.json index 3cd746c09..8fb0a5800 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "@theguild/remark-mermaid": "^0.2.0", "@types/mdx": "^2.0.13", "@types/micromatch": "^4.0.9", - "@upstash/ratelimit": "^2.0.6", "@vercel/analytics": "^1.5.0", "@vercel/functions": "^3.1.0", "@vercel/kv": "^3.0.0", diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 47c5fe2ba..e73ae06ba 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -20,7 +20,10 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types' -import { resetSignUpRateLimit, signUpRateLimit } from './ratelimit' +import { + decrementSignUpRateLimit, + incrementAndCheckSignUpRateLimit, +} from './ratelimit' export const signInWithOAuthAction = actionClient .schema( @@ -135,7 +138,8 @@ export const signUpAction = actionClient ) } - if (shouldRateLimit && (await signUpRateLimit(ip))) { + // increment rate limit counter before attempting signup + if (shouldRateLimit && (await incrementAndCheckSignUpRateLimit(ip))) { return returnServerError( 'Too many sign-up attempts. Please try again later.' ) @@ -157,10 +161,10 @@ export const signUpAction = actionClient }) if (error) { - // we reset the sign up rate limit on failure, - // since no account got registered in the end. + // decrement the sign up rate limit on failure, + // since no account was registered in the end. if (shouldRateLimit) { - resetSignUpRateLimit(ip) + decrementSignUpRateLimit(ip) } switch (error.code) { diff --git a/src/server/auth/ratelimit.ts b/src/server/auth/ratelimit.ts index cd082562f..d013e09f2 100644 --- a/src/server/auth/ratelimit.ts +++ b/src/server/auth/ratelimit.ts @@ -1,41 +1,50 @@ import 'server-cli-only' -import { Duration, Ratelimit } from '@upstash/ratelimit' +import { KV_KEYS } from '@/configs/keys' import { kv } from '@vercel/kv' const SIGN_UP_LIMIT_PER_WINDOW = parseInt( process.env.SIGN_UP_LIMIT_PER_WINDOW || '1' ) const SIGN_UP_WINDOW_HOURS = parseInt(process.env.SIGN_UP_WINDOW_HOURS || '24') - -const SIGN_UP_WINDOW: Duration = `${SIGN_UP_WINDOW_HOURS}h` - -const _ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(SIGN_UP_LIMIT_PER_WINDOW, SIGN_UP_WINDOW), -}) +const SIGN_UP_WINDOW_SECONDS = SIGN_UP_WINDOW_HOURS * 60 * 60 /** - * Checks if the sign-up rate limit has been reached for the given identifier. + * Increments the sign-up attempt counter and checks if the rate limit has been reached. + * Uses Redis INCR with a fixed time window (TTL-based). * - * @param identifier - The unique identifier (e.g., IP address, user ID) to check rate limit for - * @returns Promise - Returns true if the rate limit has been reached (no more attempts allowed), - * false if more attempts are available or if there was an error checking the limit + * @param identifier - The unique identifier (e.g., IP address) to track rate limit for + * @returns Promise - Returns true if the rate limit has been exceeded (no more attempts allowed), + * false if more attempts are available */ -export async function signUpRateLimit(identifier: string): Promise { - const result = await _ratelimit.limit(identifier) +export async function incrementAndCheckSignUpRateLimit( + identifier: string +): Promise { + const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier) + + const count = await kv.incr(key) - // we return: - // - true (is rate limited) - // - false (is not rate limited) - return !result.success + // set TTL only on the first increment to establish the time window for the rate limit + if (count === 1) { + await kv.expire(key, SIGN_UP_WINDOW_SECONDS) + } + + // return true if limit exceeded (rate limited) + return count > SIGN_UP_LIMIT_PER_WINDOW } /** - * Resets the rate limit counter for the given identifier, allowing them to make new attempts. + * Decrements the sign-up attempt counter when a sign-up fails. + * This allows the user to retry since no account was actually created. * - * @param identifier - The unique identifier whose rate limit should be reset + * @param identifier - The unique identifier whose rate limit should be decremented */ -export async function resetSignUpRateLimit(identifier: string) { - await _ratelimit.resetUsedTokens(identifier) +export async function decrementSignUpRateLimit(identifier: string) { + const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier) + const currentCount = await kv.get(key) + + // only decrement if key exists and count > 0 + if (currentCount && currentCount > 0) { + await kv.decr(key) + } } From 2010cc06ec05c59f7b81a503796fd024e0926de0 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Tue, 7 Oct 2025 14:21:15 +0200 Subject: [PATCH 17/20] refactor: use custom lua script to ensure atomic mutations --- src/server/auth/ratelimit.ts | 48 +++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/server/auth/ratelimit.ts b/src/server/auth/ratelimit.ts index d013e09f2..4b79640eb 100644 --- a/src/server/auth/ratelimit.ts +++ b/src/server/auth/ratelimit.ts @@ -11,7 +11,12 @@ const SIGN_UP_WINDOW_SECONDS = SIGN_UP_WINDOW_HOURS * 60 * 60 /** * Increments the sign-up attempt counter and checks if the rate limit has been reached. - * Uses Redis INCR with a fixed time window (TTL-based). + * Uses a Lua script for atomic execution to avoid race conditions. + * + * The script: + * 1. Increments the counter + * 2. Sets TTL on first increment + * 3. Returns the new count * * @param identifier - The unique identifier (e.g., IP address) to track rate limit for * @returns Promise - Returns true if the rate limit has been exceeded (no more attempts allowed), @@ -22,29 +27,48 @@ export async function incrementAndCheckSignUpRateLimit( ): Promise { const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier) - const count = await kv.incr(key) + // executes atomically on redis server + const luaScript = ` + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return count + ` - // set TTL only on the first increment to establish the time window for the rate limit - if (count === 1) { - await kv.expire(key, SIGN_UP_WINDOW_SECONDS) - } + const count = await kv.eval( + luaScript, + [key], + [SIGN_UP_WINDOW_SECONDS.toString()] + ) // return true if limit exceeded (rate limited) - return count > SIGN_UP_LIMIT_PER_WINDOW + return (count as number) > SIGN_UP_LIMIT_PER_WINDOW } /** * Decrements the sign-up attempt counter when a sign-up fails. + * Uses a Lua script for atomic execution to avoid race conditions. + * + * The script only decrements if: + * 1. Key exists + * 2. Current count > 0 + * * This allows the user to retry since no account was actually created. * * @param identifier - The unique identifier whose rate limit should be decremented */ export async function decrementSignUpRateLimit(identifier: string) { const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier) - const currentCount = await kv.get(key) - // only decrement if key exists and count > 0 - if (currentCount && currentCount > 0) { - await kv.decr(key) - } + // executes atomically on redis server + const luaScript = ` + local current = redis.call('GET', KEYS[1]) + if current and tonumber(current) > 0 then + return redis.call('DECR', KEYS[1]) + end + return current + ` + + await kv.eval(luaScript, [key], []) } From b837c7c3dc10339cd62002fb1fdaa0bb6a842e6e Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Tue, 7 Oct 2025 14:24:40 +0200 Subject: [PATCH 18/20] fix: missing await --- src/server/auth/auth-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index e73ae06ba..657cc7645 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -164,7 +164,7 @@ export const signUpAction = actionClient // decrement the sign up rate limit on failure, // since no account was registered in the end. if (shouldRateLimit) { - decrementSignUpRateLimit(ip) + await decrementSignUpRateLimit(ip) } switch (error.code) { From 5b7ad4b09d53792e19e42d733782054b8efdf0e5 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Tue, 7 Oct 2025 15:07:18 +0200 Subject: [PATCH 19/20] fix: ensure correct number passing + ensure ttl is always set --- src/server/auth/auth-actions.ts | 20 ++++++++++++++++++-- src/server/auth/ratelimit.ts | 27 ++++++++++++++++----------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 657cc7645..5d6634ac9 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -117,10 +117,20 @@ export const signUpAction = actionClient const ip = ipAddress(headersStore) + const SIGN_UP_LIMIT_PER_WINDOW = parseInt( + process.env.SIGN_UP_LIMIT_PER_WINDOW || '3' + ) + + const SIGN_UP_WINDOW_HOURS = parseInt( + process.env.SIGN_UP_WINDOW_HOURS || '24' + ) + const shouldRateLimit = ENABLE_SIGN_UP_RATE_LIMITING && process.env.NODE_ENV === 'production' && - ip + ip && + !isNaN(SIGN_UP_LIMIT_PER_WINDOW) && + !isNaN(SIGN_UP_WINDOW_HOURS) if ( ENABLE_SIGN_UP_RATE_LIMITING && @@ -139,7 +149,13 @@ export const signUpAction = actionClient } // increment rate limit counter before attempting signup - if (shouldRateLimit && (await incrementAndCheckSignUpRateLimit(ip))) { + if ( + shouldRateLimit && + (await incrementAndCheckSignUpRateLimit(ip, { + windowHours: SIGN_UP_WINDOW_HOURS, + limitPerWindow: SIGN_UP_LIMIT_PER_WINDOW, + })) + ) { return returnServerError( 'Too many sign-up attempts. Please try again later.' ) diff --git a/src/server/auth/ratelimit.ts b/src/server/auth/ratelimit.ts index 4b79640eb..7fa58bc2e 100644 --- a/src/server/auth/ratelimit.ts +++ b/src/server/auth/ratelimit.ts @@ -3,12 +3,6 @@ import 'server-cli-only' import { KV_KEYS } from '@/configs/keys' import { kv } from '@vercel/kv' -const SIGN_UP_LIMIT_PER_WINDOW = parseInt( - process.env.SIGN_UP_LIMIT_PER_WINDOW || '1' -) -const SIGN_UP_WINDOW_HOURS = parseInt(process.env.SIGN_UP_WINDOW_HOURS || '24') -const SIGN_UP_WINDOW_SECONDS = SIGN_UP_WINDOW_HOURS * 60 * 60 - /** * Increments the sign-up attempt counter and checks if the rate limit has been reached. * Uses a Lua script for atomic execution to avoid race conditions. @@ -23,27 +17,38 @@ const SIGN_UP_WINDOW_SECONDS = SIGN_UP_WINDOW_HOURS * 60 * 60 * false if more attempts are available */ export async function incrementAndCheckSignUpRateLimit( - identifier: string + identifier: string, + limits: { + windowHours: number + limitPerWindow: number + } ): Promise { const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier) + const { windowHours, limitPerWindow } = limits + const windowSeconds = windowHours * 60 * 60 // executes atomically on redis server + // we ensure TTL exists once per window, even if expire would fail on first increment for some reason const luaScript = ` local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) + else + if redis.call('TTL', KEYS[1]) == -1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end end return count ` - const count = await kv.eval( + const count = await kv.eval( luaScript, [key], - [SIGN_UP_WINDOW_SECONDS.toString()] + [windowSeconds.toString()] ) // return true if limit exceeded (rate limited) - return (count as number) > SIGN_UP_LIMIT_PER_WINDOW + return count > limitPerWindow } /** @@ -70,5 +75,5 @@ export async function decrementSignUpRateLimit(identifier: string) { return current ` - await kv.eval(luaScript, [key], []) + await kv.eval(luaScript, [key], []) } From 0f9995749055b7bcd17a749e2a2951f30b235476 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Tue, 7 Oct 2025 15:14:49 +0200 Subject: [PATCH 20/20] refactor: streamline sign-up rate limiting by using default values and simplifying logic --- src/configs/limits.ts | 4 ++++ src/server/auth/auth-actions.ts | 22 +++------------------- src/server/auth/ratelimit.ts | 28 ++++++++++++++++++++-------- 3 files changed, 27 insertions(+), 27 deletions(-) create mode 100644 src/configs/limits.ts diff --git a/src/configs/limits.ts b/src/configs/limits.ts new file mode 100644 index 000000000..b0289d0ca --- /dev/null +++ b/src/configs/limits.ts @@ -0,0 +1,4 @@ +const DEFAULT_SIGN_UP_LIMIT_PER_WINDOW = 3 +const DEFAULT_SIGN_UP_WINDOW_HOURS = 24 + +export { DEFAULT_SIGN_UP_LIMIT_PER_WINDOW, DEFAULT_SIGN_UP_WINDOW_HOURS } diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 5d6634ac9..b3712f9ab 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -117,20 +117,10 @@ export const signUpAction = actionClient const ip = ipAddress(headersStore) - const SIGN_UP_LIMIT_PER_WINDOW = parseInt( - process.env.SIGN_UP_LIMIT_PER_WINDOW || '3' - ) - - const SIGN_UP_WINDOW_HOURS = parseInt( - process.env.SIGN_UP_WINDOW_HOURS || '24' - ) - const shouldRateLimit = ENABLE_SIGN_UP_RATE_LIMITING && process.env.NODE_ENV === 'production' && - ip && - !isNaN(SIGN_UP_LIMIT_PER_WINDOW) && - !isNaN(SIGN_UP_WINDOW_HOURS) + ip if ( ENABLE_SIGN_UP_RATE_LIMITING && @@ -144,18 +134,12 @@ export const signUpAction = actionClient message: 'no ip headers found in production', }, }, - 'Tried to rate limit, but no ip headers were found in production.' + 'Tried to rate limit, but no client ip headers were found in production.' ) } // increment rate limit counter before attempting signup - if ( - shouldRateLimit && - (await incrementAndCheckSignUpRateLimit(ip, { - windowHours: SIGN_UP_WINDOW_HOURS, - limitPerWindow: SIGN_UP_LIMIT_PER_WINDOW, - })) - ) { + if (shouldRateLimit && (await incrementAndCheckSignUpRateLimit(ip))) { return returnServerError( 'Too many sign-up attempts. Please try again later.' ) diff --git a/src/server/auth/ratelimit.ts b/src/server/auth/ratelimit.ts index 7fa58bc2e..b8163a687 100644 --- a/src/server/auth/ratelimit.ts +++ b/src/server/auth/ratelimit.ts @@ -1,8 +1,25 @@ import 'server-cli-only' import { KV_KEYS } from '@/configs/keys' +import { + DEFAULT_SIGN_UP_LIMIT_PER_WINDOW, + DEFAULT_SIGN_UP_WINDOW_HOURS, +} from '@/configs/limits' import { kv } from '@vercel/kv' +// we need to ensure the limits are valid numbers +// if parseInt returns NaN, we fallback to the default +const SIGN_UP_LIMIT_PER_WINDOW = + parseInt( + process.env.SIGN_UP_LIMIT_PER_WINDOW || + DEFAULT_SIGN_UP_LIMIT_PER_WINDOW.toString() + ) || DEFAULT_SIGN_UP_LIMIT_PER_WINDOW + +const SIGN_UP_WINDOW_HOURS = + parseInt( + process.env.SIGN_UP_WINDOW_HOURS || DEFAULT_SIGN_UP_WINDOW_HOURS.toString() + ) || DEFAULT_SIGN_UP_WINDOW_HOURS + /** * Increments the sign-up attempt counter and checks if the rate limit has been reached. * Uses a Lua script for atomic execution to avoid race conditions. @@ -17,15 +34,10 @@ import { kv } from '@vercel/kv' * false if more attempts are available */ export async function incrementAndCheckSignUpRateLimit( - identifier: string, - limits: { - windowHours: number - limitPerWindow: number - } + identifier: string ): Promise { const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier) - const { windowHours, limitPerWindow } = limits - const windowSeconds = windowHours * 60 * 60 + const windowSeconds = SIGN_UP_WINDOW_HOURS * 60 * 60 // executes atomically on redis server // we ensure TTL exists once per window, even if expire would fail on first increment for some reason @@ -48,7 +60,7 @@ export async function incrementAndCheckSignUpRateLimit( ) // return true if limit exceeded (rate limited) - return count > limitPerWindow + return count > SIGN_UP_LIMIT_PER_WINDOW } /**