diff --git a/.env.example b/.env.example index 0bd9fcbf3..b0b2b10a2 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### OPTIONAL SERVER ENVIRONMENT VARIABLES ### ================================= +### Auth Rate Limiting +# ENABLE_SIGN_UP_RATE_LIMITING=1 +# 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/bun.lock b/bun.lock index c3edf056c..c50c5dd6a 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@types/mdx": "^2.0.13", "@types/micromatch": "^4.0.9", "@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", @@ -1166,8 +1167,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 042f5a8b8..8fb0a5800 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/mdx": "^2.0.13", "@types/micromatch": "^4.0.9", "@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/configs/flags.ts b/src/configs/flags.ts index 7171d183e..e71249c6d 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -1,6 +1,10 @@ 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' +export const ENABLE_SIGN_UP_RATE_LIMITING = + process.env.ENABLE_SIGN_UP_RATE_LIMITING === '1' && + process.env.KV_REST_API_URL && + process.env.KV_REST_API_TOKEN export const USE_MOCK_DATA = process.env.VERCEL_ENV !== 'production' && process.env.NEXT_PUBLIC_MOCK_DATA === '1' 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/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/lib/env.ts b/src/lib/env.ts index 5dde51bf6..a5307229c 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,5 +1,18 @@ import { z } from 'zod' +const NumericBoolean = z.enum(['1', '0']) + +// string that must parse as a positive number +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(), @@ -10,6 +23,10 @@ export const serverSchema = z.object({ BILLING_API_URL: z.string().url().optional(), ZEROBOUNCE_API_KEY: z.string().optional(), + ENABLE_SIGN_UP_RATE_LIMITING: NumericBoolean.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(), OTEL_EXPORTER_OTLP_PROTOCOL: z @@ -41,10 +58,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: 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/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 56687b8ea..b3712f9ab 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' @@ -13,11 +14,16 @@ import { 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 { + decrementSignUpRateLimit, + incrementAndCheckSignUpRateLimit, +} from './ratelimit' export const signInWithOAuthAction = actionClient .schema( @@ -78,7 +84,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()) { @@ -103,6 +113,40 @@ export const signUpAction = actionClient } } + // RATE LIMITING + + const ip = ipAddress(headersStore) + + const shouldRateLimit = + ENABLE_SIGN_UP_RATE_LIMITING && + process.env.NODE_ENV === 'production' && + ip + + 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 client ip headers were found in production.' + ) + } + + // increment rate limit counter before attempting signup + if (shouldRateLimit && (await incrementAndCheckSignUpRateLimit(ip))) { + return returnServerError( + 'Too many sign-up attempts. Please try again later.' + ) + } + + // SIGN UP + const { error } = await supabase.auth.signUp({ email, password, @@ -117,11 +161,21 @@ export const signUpAction = actionClient }) if (error) { + // decrement the sign up rate limit on failure, + // since no account was registered in the end. + if (shouldRateLimit) { + await decrementSignUpRateLimit(ip) + } + switch (error.code) { case 'email_exists': 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/ratelimit.ts b/src/server/auth/ratelimit.ts new file mode 100644 index 000000000..b8163a687 --- /dev/null +++ b/src/server/auth/ratelimit.ts @@ -0,0 +1,91 @@ +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. + * + * 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), + * false if more attempts are available + */ +export async function incrementAndCheckSignUpRateLimit( + identifier: string +): Promise { + const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier) + 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 + 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( + luaScript, + [key], + [windowSeconds.toString()] + ) + + // return true if limit exceeded (rate limited) + return count > 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) + + // 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], []) +} 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" + ] }