From 75c2c6d243421d253aa4e16fc951cab4ba8bb30f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 1 Jun 2026 10:04:47 -0700 Subject: [PATCH] feat(auth): add per-IP signup rate limit with Redis-backed storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-auth's built-in rate limit only guards bursts (3 req / 10s on /sign-up*), which a slow drip of signups from a single IP sails past. Add a long-window per-IP cap on /sign-up/email (default 20 / hour, configurable via SIGNUP_RATE_LIMIT_MAX and SIGNUP_RATE_LIMIT_WINDOW_SECONDS) to bound sustained abuse without affecting legitimate use — an enterprise office behind one NAT stays well under it, and SSO / org-invite onboarding use different endpoints this rule does not touch. Back the limiter with Redis when REDIS_URL is set, via rateLimit.customStorage, so counts hold across replicas and survive deploys. Without Redis it falls back to better-auth's in-memory store (unchanged behavior for self-hosters). Reads and writes fail open: a Redis outage degrades to "allow" rather than locking users out of auth. Session storage is untouched (we do not set secondaryStorage). Set advanced.ipAddress.ipAddressHeaders to prefer cf-connecting-ip so the rate-limit key is the real client IP (set by Cloudflare's edge, not forgeable by the caller), falling back to x-forwarded-for for non-Cloudflare deployments. Co-Authored-By: Claude Opus 4.8 --- apps/sim/lib/auth/auth.ts | 37 ++++++++++ apps/sim/lib/auth/rate-limit-storage.test.ts | 67 ++++++++++++++++++ apps/sim/lib/auth/rate-limit-storage.ts | 72 ++++++++++++++++++++ apps/sim/lib/core/config/env.ts | 2 + 4 files changed, 178 insertions(+) create mode 100644 apps/sim/lib/auth/rate-limit-storage.test.ts create mode 100644 apps/sim/lib/auth/rate-limit-storage.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index e84123de557..e921cbb5fdd 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -38,6 +38,7 @@ import { resolveClientMetadata, upsertCimdClient, } from '@/lib/auth/cimd' +import { buildRedisRateLimitStorage } from '@/lib/auth/rate-limit-storage' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { @@ -168,6 +169,20 @@ function isSignupEmailBlocked(email: string | undefined | null): boolean { return isEmailInDenylist(email, blockedSignupDomains) } +const DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS = 3600 +const DEFAULT_SIGNUP_RATE_LIMIT_MAX = 20 + +const signupRateLimitWindowSeconds = + Number(env.SIGNUP_RATE_LIMIT_WINDOW_SECONDS) || DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS +const signupRateLimitMax = Number(env.SIGNUP_RATE_LIMIT_MAX) || DEFAULT_SIGNUP_RATE_LIMIT_MAX + +/** + * Shared store for the auth rate limiter. Redis-backed when configured so per-IP + * counts hold across replicas and deploys; otherwise `undefined`, leaving + * better-auth on its default in-memory store. + */ +const authRateLimitStorage = buildRedisRateLimitStorage() + const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) => logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value }) ) @@ -204,6 +219,28 @@ export const auth = betterAuth({ provider: 'pg', schema, }), + rateLimit: { + // Mirror better-auth's default (on in production, off in dev/test) so local + // signup flows stay unthrottled. + enabled: env.NODE_ENV === 'production', + customRules: { + // Long-window per-IP cap on email signup. better-auth's built-in rule only + // guards bursts (3 / 10s), which a slow drip from one IP sails past; this + // bounds sustained abuse. Deliberately generous — an enterprise office + // behind a single NAT signs up well under this, and SSO / org-invite + // onboarding hit different endpoints that this rule does not touch. + '/sign-up/email': { window: signupRateLimitWindowSeconds, max: signupRateLimitMax }, + }, + ...(authRateLimitStorage ? { customStorage: authRateLimitStorage } : {}), + }, + advanced: { + ipAddress: { + // Prefer Cloudflare's client IP (set by the edge, not forgeable by the + // caller) so the rate-limit key is the real client. Fall back to + // X-Forwarded-For for deployments without Cloudflare in front. + ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'], + }, + }, session: { cookieCache: { enabled: true, diff --git a/apps/sim/lib/auth/rate-limit-storage.test.ts b/apps/sim/lib/auth/rate-limit-storage.test.ts new file mode 100644 index 00000000000..8326cbc7c73 --- /dev/null +++ b/apps/sim/lib/auth/rate-limit-storage.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetRedisClient, redis } = vi.hoisted(() => { + const redis = { + get: vi.fn(), + set: vi.fn(), + } + return { mockGetRedisClient: vi.fn(), redis } +}) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: mockGetRedisClient, +})) + +import { buildRedisRateLimitStorage } from '@/lib/auth/rate-limit-storage' + +describe('buildRedisRateLimitStorage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetRedisClient.mockReturnValue(redis) + }) + + it('returns undefined when Redis is not configured (falls back to in-memory)', () => { + mockGetRedisClient.mockReturnValue(null) + expect(buildRedisRateLimitStorage()).toBeUndefined() + }) + + it('reads and parses a stored counter', async () => { + const storage = buildRedisRateLimitStorage() + redis.get.mockResolvedValue(JSON.stringify({ key: 'k', count: 4, lastRequest: 123 })) + const value = await storage?.get('k') + expect(redis.get).toHaveBeenCalledWith('auth-rl:k') + expect(value).toEqual({ key: 'k', count: 4, lastRequest: 123 }) + }) + + it('returns undefined when no counter is stored', async () => { + const storage = buildRedisRateLimitStorage() + redis.get.mockResolvedValue(null) + expect(await storage?.get('missing')).toBeUndefined() + }) + + it('writes the counter with a bounding TTL', async () => { + const storage = buildRedisRateLimitStorage() + await storage?.set('k', { key: 'k', count: 1, lastRequest: 999 }) + expect(redis.set).toHaveBeenCalledWith( + 'auth-rl:k', + JSON.stringify({ key: 'k', count: 1, lastRequest: 999 }), + 'EX', + 3600 + ) + }) + + it('fails open on a read error (allows the request)', async () => { + const storage = buildRedisRateLimitStorage() + redis.get.mockRejectedValue(new Error('redis down')) + expect(await storage?.get('k')).toBeUndefined() + }) + + it('swallows write errors so a Redis outage never blocks auth', async () => { + const storage = buildRedisRateLimitStorage() + redis.set.mockRejectedValue(new Error('redis down')) + await expect(storage?.set('k', { key: 'k', count: 1, lastRequest: 1 })).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/auth/rate-limit-storage.ts b/apps/sim/lib/auth/rate-limit-storage.ts new file mode 100644 index 00000000000..95c41029f2b --- /dev/null +++ b/apps/sim/lib/auth/rate-limit-storage.ts @@ -0,0 +1,72 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { getRedisClient } from '@/lib/core/config/redis' + +const logger = createLogger('AuthRateLimitStorage') + +/** Counter shape better-auth persists per rate-limit key. */ +interface RateLimitRecord { + key: string + count: number + lastRequest: number +} + +/** Structural match for better-auth's `rateLimit.customStorage` option. */ +interface RateLimitStorage { + get: (key: string) => Promise + set: (key: string, value: RateLimitRecord, update?: boolean) => Promise +} + +const REDIS_KEY_PREFIX = 'auth-rl:' + +/** + * TTL for stored counters. Correctness comes from the `lastRequest` timestamp + * comparison in better-auth's limiter, not from expiry — the TTL only bounds + * key growth. It must be at least as long as the largest configured window so a + * key never expires mid-window and under-counts. + */ +const REDIS_TTL_SECONDS = 3600 + +/** + * Redis-backed storage for better-auth's rate limiter. + * + * Returns `undefined` when no Redis is configured, in which case better-auth + * falls back to its default in-memory store. That store is per-process and + * resets on deploy, so a long-window per-IP cap only holds across a multi-replica + * deployment when counts live in shared storage. Backing the limiter with Redis + * (rather than the primary database) keeps the per-request counter I/O off the + * Postgres primary. + * + * Fail-open: any Redis error resolves `get` to `undefined` (treated as no prior + * requests) and makes `set` a no-op, so a Redis outage degrades to "allow" + * rather than locking users out of authentication. + */ +export function buildRedisRateLimitStorage(): RateLimitStorage | undefined { + if (!getRedisClient()) return undefined + + return { + get: async (key) => { + try { + const redis = getRedisClient() + if (!redis) return undefined + const raw = await redis.get(`${REDIS_KEY_PREFIX}${key}`) + if (!raw) return undefined + return JSON.parse(raw) as RateLimitRecord + } catch (error) { + logger.warn('Rate-limit storage read failed; allowing request', { + error: getErrorMessage(error), + }) + return undefined + } + }, + set: async (key, value) => { + try { + const redis = getRedisClient() + if (!redis) return + await redis.set(`${REDIS_KEY_PREFIX}${key}`, JSON.stringify(value), 'EX', REDIS_TTL_SECONDS) + } catch (error) { + logger.warn('Rate-limit storage write failed', { error: getErrorMessage(error) }) + } + }, + } +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 3b871859295..98b16e95de6 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -29,6 +29,8 @@ export const env = createEnv({ BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com") SIGNUP_MX_VALIDATION_ENABLED: z.boolean().optional(), // Opt-in: validate the email's MX backend at signup (blocks no-MX domains and denylisted shared spam backends). Off by default; enable on hosted/abuse-targeted deployments. BLOCKED_EMAIL_MX_HOSTS: z.string().optional(), // Comma-separated MX-host substrings blocked from signing up; matched against the domain's resolved MX backend to catch throwaway domains that share a mail backend. No defaults — operators supply their own list. Only used when SIGNUP_MX_VALIDATION_ENABLED is set. + SIGNUP_RATE_LIMIT_WINDOW_SECONDS: z.string().optional(), // Per-IP rate-limit window (seconds) for /sign-up/email. Default 3600 (1h). Backed by Redis when REDIS_URL is set (consistent across replicas); falls back to better-auth's in-memory store otherwise. + SIGNUP_RATE_LIMIT_MAX: z.string().optional(), // Max email signups per IP per window before HTTP 429. Default 20 — generous for enterprise office NATs; SSO and org-invite onboarding use different endpoints and are unaffected. TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins. TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains)