From d854d73e0507a0e3112777da32567aa6ed406fa2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:04:50 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Add=20d?= =?UTF-8?q?ual=20rate-limiting=20for=20auth=20server=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement rateLimitDual in src/lib/rate-limit.ts to evaluate client IP rate limits prior to target email rate limits, safeguarding signUpAction and signInAction against brute-force, credential stuffing, and Account Lockout DoS attacks. Include comprehensive unit tests in src/lib/__tests__/rate-limit.test.ts. Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com> --- src/app/actions/auth.ts | 10 ++--- src/lib/__tests__/rate-limit.test.ts | 53 ++++++++++++++++++++++++ src/lib/rate-limit.ts | 61 ++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/rate-limit.test.ts diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index 2f10075..05cc7d6 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,7 @@ import { // --------------------------------------------------------------------------- export const signUpAction = createSafeAction(signUpSchema, async (data) => { - const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000); + const rl = await rateLimitDual('signup', data.email.toLowerCase()); if (!rl.allowed) { throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); } @@ -123,9 +123,9 @@ 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); + // Rate-limit BEFORE any DB or scrypt work — dual rate limit checks IP first, + // then target email, blunting brute force, account lockout DoS, and loop abuse. + const rl = await rateLimitDual('signin', data.email.toLowerCase()); 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..7d5e603 --- /dev/null +++ b/src/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { rateLimit, rateLimitDual, resetRateLimits } from '../rate-limit'; + +// Mock next/headers +vi.mock('next/headers', () => ({ + headers: vi.fn().mockResolvedValue({ + get: (header: string) => { + if (header === 'x-forwarded-for') return '192.168.1.100, 10.0.0.1'; + if (header === 'x-real-ip') return '192.168.1.100'; + return null; + }, + }), +})); + +describe('rate-limit module', () => { + beforeEach(() => { + resetRateLimits(); + }); + + describe('rateLimit', () => { + it('allows hits up to limit and blocks subsequent hits', () => { + for (let i = 0; i < 3; i++) { + const res = rateLimit('test-key', 3, 60_000); + expect(res.allowed).toBe(true); + } + const blocked = rateLimit('test-key', 3, 60_000); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterSeconds).toBeGreaterThan(0); + }); + }); + + describe('rateLimitDual', () => { + it('enforces target rate limit when IP is within bounds', async () => { + const email = 'user@example.com'; + for (let i = 0; i < 5; i++) { + const res = await rateLimitDual('signin', email, { limit: 5, ipLimit: 10 }); + expect(res.allowed).toBe(true); + } + const blocked = await rateLimitDual('signin', email, { limit: 5, ipLimit: 10 }); + expect(blocked.allowed).toBe(false); + }); + + it('enforces IP rate limit prior to target limit', async () => { + for (let i = 0; i < 3; i++) { + const res = await rateLimitDual('signin', `user${i}@example.com`, { limit: 5, ipLimit: 3 }); + expect(res.allowed).toBe(true); + } + // IP limit reached (3 hits from 192.168.1.100) + const blockedByIp = await rateLimitDual('signin', 'newuser@example.com', { limit: 5, ipLimit: 3 }); + expect(blockedByIp.allowed).toBe(false); + }); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 5d0ef23..cf177df 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(); @@ -17,6 +18,41 @@ export interface RateLimitResult { retryAfterSeconds: number; } +export interface RateLimitOptions { + /** Maximum hits for the target key (default: 5) */ + limit?: number; + /** Maximum hits for the client IP (default: 10) */ + ipLimit?: number; + /** Sliding window duration in ms (default: 60,000 / 1 min) */ + windowMs?: number; +} + +/** + * Resets all rate-limiting buckets. Intended for unit tests. + */ +export function resetRateLimits(): void { + buckets.clear(); +} + +/** + * Retrieve client IP address from incoming HTTP headers safely. + */ +export async function getClientIp(): Promise { + try { + const reqHeaders = await headers(); + const xff = reqHeaders.get('x-forwarded-for'); + if (xff) { + const clientIp = xff.split(',')[0]?.trim(); + if (clientIp) return clientIp; + } + const realIp = reqHeaders.get('x-real-ip'); + if (realIp) return realIp.trim(); + } catch { + // headers() throws if called outside request context + } + return null; +} + /** * Record a hit for `key` and report whether it stays within `limit` hits * per `windowMs`. Denied hits are not recorded (a blocked attacker doesn't @@ -47,3 +83,28 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR return { allowed: true, retryAfterSeconds: 0 }; } + +/** + * Dual rate-limiter that checks both client IP and target key (e.g. email). + * Crucially, IP limit is evaluated FIRST to prevent an IP-blocked attacker + * from consuming or polluting target-based buckets (preventing Account Lockout DoS). + */ +export async function rateLimitDual( + actionPrefix: string, + targetKey: string, + options: RateLimitOptions = {}, +): Promise { + const windowMs = options.windowMs ?? 60_000; + const targetLimit = options.limit ?? 5; + const ipLimit = options.ipLimit ?? 10; + + const ip = await getClientIp(); + if (ip) { + const ipRes = rateLimit(`${actionPrefix}:ip:${ip}`, ipLimit, windowMs); + if (!ipRes.allowed) { + return ipRes; + } + } + + return rateLimit(`${actionPrefix}:target:${targetKey}`, targetLimit, windowMs); +} From efa98d577f9c672418e23b88d6e5551fe5b75667 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:12:41 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Add=20d?= =?UTF-8?q?ual=20rate-limiting=20for=20auth=20server=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement rateLimitDual in src/lib/rate-limit.ts to evaluate client IP rate limits prior to target email rate limits, safeguarding signUpAction and signInAction against brute-force, credential stuffing, and Account Lockout DoS attacks. Include comprehensive unit tests in src/lib/__tests__/rate-limit.test.ts. Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com> From d2112a7bd6843d445e9a59dd90e1e4759c7da0d5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:20:07 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Add=20d?= =?UTF-8?q?ual=20rate-limiting=20for=20auth=20server=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement rateLimitDual in src/lib/rate-limit.ts to evaluate client IP rate limits prior to target email rate limits, safeguarding signUpAction and signInAction against brute-force, credential stuffing, and Account Lockout DoS attacks. Pin pnpm@11.14.0 across package.json and GitHub workflows. Include unit tests in src/lib/__tests__/rate-limit.test.ts. Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com> --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/sentry-alert.yml | 2 ++ package.json | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ad45b2..b0e0a0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,8 @@ jobs: fetch-depth: 0 - uses: pnpm/action-setup@v6 + with: + version: 11.14.0 - uses: actions/setup-node@v7 with: @@ -134,6 +136,8 @@ jobs: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 + with: + version: 11.14.0 - uses: actions/setup-node@v7 with: @@ -196,6 +200,8 @@ jobs: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 + with: + version: 11.14.0 - uses: actions/setup-node@v7 with: diff --git a/.github/workflows/sentry-alert.yml b/.github/workflows/sentry-alert.yml index be2c7ab..ab6aab8 100644 --- a/.github/workflows/sentry-alert.yml +++ b/.github/workflows/sentry-alert.yml @@ -27,6 +27,8 @@ jobs: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 + with: + version: 11.14.0 - uses: actions/setup-node@v7 with: diff --git a/package.json b/package.json index e60c440..2898b36 100644 --- a/package.json +++ b/package.json @@ -72,5 +72,5 @@ "eslint --fix" ] }, - "packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a" + "packageManager": "pnpm@11.14.0" }