diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 29abdfb..3787ce5 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -4,3 +4,8 @@ **Vulnerability:** The application used `scryptSync` (synchronous CPU-intensive password hashing) inside Next.js server action handlers for registration and login. Because Node.js runs on a single main event loop, a small number of concurrent authentication requests (or a distributed credential stuffing attack) completely blocks the event loop, starving all other concurrent requests and causing a full Denial of Service (DoS). **Learning:** Next.js Server Actions and Route Handlers run on Node's main thread by default. Using synchronous cryptography operations (such as `scryptSync` or `pbkdf2Sync`) prevents the server from processing other concurrent connections. **Prevention:** Always use asynchronous password-hashing implementations (such as async `scrypt` wrapped in a Promise or bcrypt/argon2 async variants) inside Next.js/Node.js web entry points to delegate heavy hashing computations to the Node.js libuv thread pool, keeping the main event loop responsive. + +## 2026-07-17 - Account Lockout Denial of Service via Target-only Rate Limiting +**Vulnerability:** Critical authentication actions (sign-in and sign-up) relied exclusively on target-based (email) rate-limiting buckets. This design allowed malicious actors to lock out legitimate users from their accounts by repeatedly submitting failed authentication requests under the target's email address, causing an Account Lockout Denial of Service (DoS). It also failed to protect against distributed brute-force attacks across many accounts from a single IP. +**Learning:** Rate-limiting designs must account for both IP-based and target-based threats. Additionally, evaluating target-based buckets before IP-based buckets allows blocked IP actors to still pollute and exceed the target's rate-limiting budget. +**Prevention:** Implement dual rate-limiting combining client IP (checked first) and target identifiers. Always block based on the IP rate limit first, bailing out early to prevent malicious, blocked actors from contaminating the target-based rate limits of legitimate users. 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" } 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..ceb0455 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); if (!rl.allowed) { throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); } @@ -125,7 +125,7 @@ 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); + const rl = await rateLimitDual('signin', data.email); 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..b82146b --- /dev/null +++ b/src/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { rateLimitDual, resetRateLimits } from '@/lib/rate-limit'; + +const mockHeadersMap = new Map(); + +vi.mock('next/headers', () => { + return { + headers: () => + Promise.resolve({ + get: (key: string) => mockHeadersMap.get(key) ?? null, + }), + }; +}); + +describe('rateLimitDual', () => { + beforeEach(() => { + resetRateLimits(); + mockHeadersMap.clear(); + }); + + it('allows requests within limit', async () => { + mockHeadersMap.set('x-forwarded-for', '1.2.3.4'); + + const res = await rateLimitDual('login', 'user@example.com', { + ipLimit: 2, + targetLimit: 2, + }); + + expect(res.allowed).toBe(true); + expect(res.retryAfterSeconds).toBe(0); + }); + + it('blocks by IP limit and does not pollute target bucket (Account Lockout DoS prevention)', async () => { + // We set ipLimit to 2, targetLimit to 2. + // IP 1.2.3.4 will make 3 requests for user@example.com. + mockHeadersMap.set('x-forwarded-for', '1.2.3.4'); + + const res1 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 2, + targetLimit: 2, + }); + expect(res1.allowed).toBe(true); + + const res2 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 2, + targetLimit: 2, + }); + expect(res2.allowed).toBe(true); + + // Third request from IP 1.2.3.4 should be blocked by IP-level limit. + const res3 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 2, + targetLimit: 2, + }); + expect(res3.allowed).toBe(false); + expect(res3.retryAfterSeconds).toBeGreaterThan(0); + + // Now, a legitimate user from a different IP (5.6.7.8) tries to log in. + // Since the blocked 3rd request from IP 1.2.3.4 did not pollute the target bucket, + // the target bucket for user@example.com should only have 2 active hits (from res1 and res2). + // Let's verify that IP 5.6.7.8 can still make their first allowed request for user@example.com, + // but a subsequent one is blocked by targetLimit (since total hits for target is now 3). + mockHeadersMap.set('x-forwarded-for', '5.6.7.8'); + + // First request from 5.6.7.8 is blocked because targetLimit is 2, and we have 2 hits already. + // Wait! Let's double check this logic: + // res1 and res2 were allowed, so target bucket has 2 hits. + // So the target-level limit (2) is reached. + // This is correct! + const res4 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 2, + targetLimit: 2, + }); + expect(res4.allowed).toBe(false); + }); + + it('target bucket is not incremented at all on blocked IP requests', async () => { + // Let's verify that if IP limit is reached, target bucket has absolutely 0 additional hits. + // Let's set ipLimit to 1, targetLimit to 5. + mockHeadersMap.set('x-forwarded-for', '1.2.3.4'); + + // 1st request from IP 1.2.3.4: allowed (target gets 1 hit) + const r1 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 1, + targetLimit: 5, + }); + expect(r1.allowed).toBe(true); + + // 2nd request from IP 1.2.3.4: blocked by IP (target should NOT get a 2nd hit) + const r2 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 1, + targetLimit: 5, + }); + expect(r2.allowed).toBe(false); + + // 3rd request from IP 1.2.3.4: blocked by IP (target should NOT get a 3rd hit) + const r3 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 1, + targetLimit: 5, + }); + expect(r3.allowed).toBe(false); + + // Now switch to IP 5.6.7.8. + // If the blocked requests did not pollute, the target bucket has exactly 1 hit. + // We should be able to make 4 more allowed requests from IP 5.6.7.8 (since targetLimit is 5). + mockHeadersMap.set('x-forwarded-for', '5.6.7.8'); + + const r4 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 5, // high IP limit on the new IP + targetLimit: 5, + }); + expect(r4.allowed).toBe(true); // target hit 2 + + const r5 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 5, + targetLimit: 5, + }); + expect(r5.allowed).toBe(true); // target hit 3 + + const r6 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 5, + targetLimit: 5, + }); + expect(r6.allowed).toBe(true); // target hit 4 + + const r7 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 5, + targetLimit: 5, + }); + expect(r7.allowed).toBe(true); // target hit 5 + + // 6th target hit overall: should be blocked by targetLimit + const r8 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 5, + targetLimit: 5, + }); + expect(r8.allowed).toBe(false); + }); + + it('safely extracts IP from x-forwarded-for with multiple IPs and spaces', async () => { + mockHeadersMap.set('x-forwarded-for', ' 192.168.0.1, 10.0.0.1 '); + const res = await rateLimitDual('login', 'user@example.com', { + ipLimit: 1, + }); + expect(res.allowed).toBe(true); + + // Next request from same x-forwarded-for (first IP is trimmed and isolated) should be blocked if limit is 1 + const res2 = await rateLimitDual('login', 'user@example.com', { + ipLimit: 1, + }); + expect(res2.allowed).toBe(false); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 5d0ef23..83f114e 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(); @@ -47,3 +48,43 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR return { allowed: true, retryAfterSeconds: 0 }; } + +/** + * Reset all rate-limiting buckets (primarily for testing to avoid inter-test pollution). + */ +export function resetRateLimits(): void { + buckets.clear(); +} + +/** + * Asynchronous dual rate-limiting utility function. + * Safeguards sensitive endpoints like signUpAction and signInAction against brute force + * and credential stuffing by constraining both the client IP and target-based identifiers. + * + * Always executes the client IP check and rate limiting before checking target-based + * identifiers (such as emails) to prevent malicious IP-blocked actors from polluting + * target-based buckets and causing an Account Lockout Denial of Service (DoS) for legitimate users. + */ +export async function rateLimitDual( + actionName: string, + targetId: string, + options: { + ipLimit?: number; + ipWindowMs?: number; + targetLimit?: number; + targetWindowMs?: number; + } = {}, +): 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') ?? '127.0.0.1'; + + const { ipLimit = 10, ipWindowMs = 60_000, targetLimit = 5, targetWindowMs = 60_000 } = options; + + const ipResult = rateLimit(`ip:${actionName}:${ip}`, ipLimit, ipWindowMs); + if (!ipResult.allowed) { + return ipResult; + } + + return rateLimit(`target:${actionName}:${targetId.toLowerCase()}`, targetLimit, targetWindowMs); +}