From 53f7249ca95a61fa5e3f8560c1990a64bef5f7e6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:13:25 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20implement?= =?UTF-8?q?=20dual=20rate-limiting=20for=20auth=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added rateLimitDual helper in rate-limit.ts to limit requests by both client IP and target email. - Integrated rateLimitDual into signUpAction and signInAction. - Created robust unit test coverage in rate-limit.test.ts. Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com> --- .../actions/__tests__/auth-actions.test.ts | 3 + src/app/actions/auth.ts | 8 +- src/lib/__tests__/rate-limit.test.ts | 163 ++++++++++++++++++ src/lib/rate-limit.ts | 32 ++++ 4 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/rate-limit.test.ts diff --git a/src/app/actions/__tests__/auth-actions.test.ts b/src/app/actions/__tests__/auth-actions.test.ts index 766ee9b..e2632bb 100644 --- a/src/app/actions/__tests__/auth-actions.test.ts +++ b/src/app/actions/__tests__/auth-actions.test.ts @@ -29,6 +29,9 @@ vi.mock('next/headers', () => ({ set: vi.fn(), delete: vi.fn(), }), + headers: () => Promise.resolve({ + get: () => null, + }), })); vi.mock('next/navigation', () => ({ diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index 2f10075..4d0db76 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -15,7 +15,7 @@ import { getSession, } from '@/lib/auth'; import { logger } from '@/lib/logger'; -import { rateLimit } from '@/lib/rate-limit'; +import { rateLimitDual } from '@/lib/rate-limit'; import { hashClaimToken, PLACEHOLDER_PASSWORD_PREFIX, @@ -32,7 +32,8 @@ import { // --------------------------------------------------------------------------- export const signUpAction = createSafeAction(signUpSchema, async (data) => { - const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000); + // Dual rate limiting: limit IP to 10 attempts and the target email to 5 attempts per windowMs. + const rl = await rateLimitDual(data.email, 10, 5, 60_000); if (!rl.allowed) { throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); } @@ -125,7 +126,8 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => { export const signInAction = createSafeAction(signInSchema, async (data) => { // Rate-limit BEFORE any DB or scrypt work — the sync scrypt verify is // exactly what an attacker would use to burn the event loop. - const rl = rateLimit(`signin:${data.email.toLowerCase()}`, 5, 60_000); + // Dual rate limiting: limit IP to 10 attempts and the target email to 5 attempts per windowMs. + const rl = await rateLimitDual(data.email, 10, 5, 60_000); if (!rl.allowed) { throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); } diff --git a/src/lib/__tests__/rate-limit.test.ts b/src/lib/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..1c3ecbe --- /dev/null +++ b/src/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('next/headers', () => ({ + headers: vi.fn(), +})); + +import { rateLimit, rateLimitDual } from '../rate-limit'; +import { headers } from 'next/headers'; + +describe('rate-limit.ts', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('rateLimit', () => { + it('allows hits up to limit within window', () => { + const key = 'test-key-1'; + // Limit 3 + expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 }); + expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 }); + expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 }); + // 4th hit blocked + const res = rateLimit(key, 3, 60_000); + expect(res.allowed).toBe(false); + expect(res.retryAfterSeconds).toBe(60); + }); + + it('sliding window lets older hits fall out and allows new hits', () => { + const key = 'test-key-2'; + // 3 hits at t=0 + rateLimit(key, 3, 60_000); + rateLimit(key, 3, 60_000); + rateLimit(key, 3, 60_000); + + expect(rateLimit(key, 3, 60_000).allowed).toBe(false); + + // Advance time by 30 seconds + vi.advanceTimersByTime(30_000); + expect(rateLimit(key, 3, 60_000).allowed).toBe(false); + + // Advance by another 31 seconds (total 61s from start) + vi.advanceTimersByTime(31_000); + // Older hits should have fallen out + expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 }); + }); + + it('opportunistically cleans up map when it grows unbounded', () => { + // Create over 10,000 buckets + // Fill the Map buckets with old timestamps + const now = Date.now(); + const cutoff = now - 60_000; + + // We can trigger cleanup by exceeding 10,000 bucket size. + // Let's call rateLimit with 10,005 unique keys. + for (let i = 0; i < 10005; i++) { + rateLimit(`cleanup-key-${i}`, 5, 60_000); + } + + // Now let's advance time by 61 seconds so that all of them are considered expired. + vi.advanceTimersByTime(61_000); + + // Call rateLimit one more time to trigger cleanup. + // This should clean up all the older keys because all of them are <= cutoff. + rateLimit('trigger-cleanup', 5, 60_000); + + // To verify cleanup happened, if we check again, it shouldn't hit memory limits or map bounds. + // The map size has been significantly reduced internally. + // Let's assert that a new key is allowed. + expect(rateLimit('new-key', 5, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 }); + }); + }); + + describe('rateLimitDual', () => { + it('extracts IP from x-forwarded-for first IP and limits requests', async () => { + const mockHeaders = { + get: vi.fn((name: string) => { + if (name === 'x-forwarded-for') return '192.168.1.100, 10.0.0.1'; + return null; + }), + }; + (headers as unknown as ReturnType).mockResolvedValue(mockHeaders); + + // Call it 3 times with same email and IP + // IP limit = 2, Email limit = 3 + const email = 'user1@example.com'; + const r1 = await rateLimitDual(email, 2, 3, 60_000); + expect(r1.allowed).toBe(true); + + const r2 = await rateLimitDual(email, 2, 3, 60_000); + expect(r2.allowed).toBe(true); + + // Third call should be blocked by IP limit + const r3 = await rateLimitDual(email, 2, 3, 60_000); + expect(r3.allowed).toBe(false); + expect(r3.retryAfterSeconds).toBe(60); + }); + + it('extracts IP from x-real-ip if x-forwarded-for is missing', async () => { + const mockHeaders = { + get: vi.fn((name: string) => { + if (name === 'x-real-ip') return '203.0.113.1'; + return null; + }), + }; + (headers as unknown as ReturnType).mockResolvedValue(mockHeaders); + + // IP limit = 1, Email limit = 2 + const email = 'user2@example.com'; + const r1 = await rateLimitDual(email, 1, 2, 60_000); + expect(r1.allowed).toBe(true); + + const r2 = await rateLimitDual(email, 1, 2, 60_000); + expect(r2.allowed).toBe(false); + }); + + it('defaults to unknown-ip if both headers are missing', async () => { + const mockHeaders = { + get: vi.fn(() => null), + }; + (headers as unknown as ReturnType).mockResolvedValue(mockHeaders); + + // IP limit = 1, Email limit = 2 + const email = 'user3@example.com'; + const r1 = await rateLimitDual(email, 1, 2, 60_000); + expect(r1.allowed).toBe(true); + + const r2 = await rateLimitDual(email, 1, 2, 60_000); + expect(r2.allowed).toBe(false); + }); + + it('blocks on email rate limit even if IP limit is not reached', async () => { + // Let's mock a scenario where same email is targeted from different IPs (credential stuffing) + let currentIp = '1.1.1.1'; + const mockHeaders = { + get: vi.fn((name: string) => { + if (name === 'x-real-ip') return currentIp; + return null; + }), + }; + (headers as unknown as ReturnType).mockResolvedValue(mockHeaders); + + const email = 'target@example.com'; + // IP limit = 5, Email limit = 2 + // Hit 1 from IP 1.1.1.1 + currentIp = '1.1.1.1'; + expect((await rateLimitDual(email, 5, 2, 60_000)).allowed).toBe(true); + + // Hit 2 from IP 2.2.2.2 + currentIp = '2.2.2.2'; + expect((await rateLimitDual(email, 5, 2, 60_000)).allowed).toBe(true); + + // Hit 3 from IP 3.3.3.3 - should block on email limit (2) + currentIp = '3.3.3.3'; + const res = await rateLimitDual(email, 5, 2, 60_000); + expect(res.allowed).toBe(false); + }); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 5d0ef23..ca1926e 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -8,6 +8,7 @@ */ import 'server-only'; +import { headers } from 'next/headers'; const buckets = new Map(); @@ -22,6 +23,37 @@ export interface RateLimitResult { * per `windowMs`. Denied hits are not recorded (a blocked attacker doesn't * extend their own lockout window). */ +/** + * Dual rate limiter combining IP-based and email-based limits. + * Protects against credential stuffing and brute-force attacks by limiting + * requests per IP address AND per target email account. + */ +export async function rateLimitDual( + email: string, + ipLimit = 10, + emailLimit = 5, + windowMs = 60_000, +): Promise { + const heads = await headers(); + const xff = heads.get('x-forwarded-for'); + const ip = (xff ? xff.split(',')[0]?.trim() : null) ?? heads.get('x-real-ip') ?? 'unknown-ip'; + const lowercaseEmail = email.toLowerCase(); + + // First, check and record the IP limit + const ipRes = rateLimit(`ip:${ip}`, ipLimit, windowMs); + if (!ipRes.allowed) { + return ipRes; + } + + // Next, check and record the target email limit + const emailRes = rateLimit(`email:${lowercaseEmail}`, emailLimit, windowMs); + if (!emailRes.allowed) { + return emailRes; + } + + return { allowed: true, retryAfterSeconds: 0 }; +} + export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitResult { const now = Date.now(); const cutoff = now - windowMs;