Skip to content

Commit ab510f7

Browse files
🛡️ Sentinel: dual rate limiting for auth server actions
- Implement dual rate limiting in `src/lib/rate-limit.ts` checking client IP first, then target identifier - Update `signUpAction` and `signInAction` in `src/app/actions/auth.ts` to use `rateLimitDual` - Add unit tests in `src/lib/__tests__/rate-limit.test.ts` - Update Sentinel journal in `.jules/sentinel.md` Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com>
1 parent 9d0e0bf commit ab510f7

4 files changed

Lines changed: 137 additions & 5 deletions

File tree

‎.jules/sentinel.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@
44
**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).
55
**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.
66
**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.
7+
8+
## 2026-07-19 - Single-Key Identifier Rate Limiting Enables Account Lockout DoS
9+
**Vulnerability:** Authentication server actions (`signUpAction`, `signInAction`) previously rate-limited requests solely by target email (`signup:email` or `signin:email`). An attacker could trigger lockout for target emails without affecting their own capacity, or launch distributed credential stuffing from a single IP address across many accounts.
10+
**Learning:** Single-key rate limiting indexed by target identifier creates an asymmetric attack vector where attackers cause Denial of Service against victim accounts (Account Lockout DoS).
11+
**Prevention:** Use dual rate limiting (`rateLimitDual`) on sensitive authentication actions: evaluate client IP rate limits first before checking target identifier limits. Checking IP limits first prevents blocked attacker IPs from consuming or polluting target-based rate limit buckets.

‎src/app/actions/auth.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
getSession,
1616
} from '@/lib/auth';
1717
import { logger } from '@/lib/logger';
18-
import { rateLimit } from '@/lib/rate-limit';
18+
import { rateLimitDual } from '@/lib/rate-limit';
1919
import {
2020
hashClaimToken,
2121
PLACEHOLDER_PASSWORD_PREFIX,
@@ -32,7 +32,7 @@ import {
3232
// ---------------------------------------------------------------------------
3333

3434
export const signUpAction = createSafeAction(signUpSchema, async (data) => {
35-
const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000);
35+
const rl = await rateLimitDual('signup', data.email, 20, 5, 60_000);
3636
if (!rl.allowed) {
3737
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
3838
}
@@ -123,9 +123,9 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => {
123123
// ---------------------------------------------------------------------------
124124

125125
export const signInAction = createSafeAction(signInSchema, async (data) => {
126-
// Rate-limit BEFORE any DB or scrypt work — the sync scrypt verify is
127-
// exactly what an attacker would use to burn the event loop.
128-
const rl = rateLimit(`signin:${data.email.toLowerCase()}`, 5, 60_000);
126+
// Dual rate-limit BEFORE any DB or scrypt work — checking IP first protects
127+
// both Node event loop and prevents Account Lockout DoS against target emails.
128+
const rl = await rateLimitDual('signin', data.email, 20, 5, 60_000);
129129
if (!rl.allowed) {
130130
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
131131
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { rateLimit, rateLimitDual, resetRateLimits } from '../rate-limit';
3+
4+
describe('rateLimit', () => {
5+
beforeEach(() => {
6+
resetRateLimits();
7+
});
8+
9+
it('allows requests within limit', () => {
10+
const res1 = rateLimit('test-key', 2, 60_000);
11+
expect(res1.allowed).toBe(true);
12+
expect(res1.retryAfterSeconds).toBe(0);
13+
14+
const res2 = rateLimit('test-key', 2, 60_000);
15+
expect(res2.allowed).toBe(true);
16+
});
17+
18+
it('blocks requests exceeding limit', () => {
19+
rateLimit('test-key', 2, 60_000);
20+
rateLimit('test-key', 2, 60_000);
21+
22+
const res3 = rateLimit('test-key', 2, 60_000);
23+
expect(res3.allowed).toBe(false);
24+
expect(res3.retryAfterSeconds).toBeGreaterThan(0);
25+
});
26+
27+
it('does not record denied hits (does not extend penalty window)', () => {
28+
vi.useFakeTimers();
29+
const now = Date.now();
30+
vi.setSystemTime(now);
31+
32+
rateLimit('test-key', 1, 60_000); // 1st hit -> allowed
33+
rateLimit('test-key', 1, 60_000); // 2nd hit -> blocked
34+
35+
// Advance time 61s
36+
vi.setSystemTime(now + 61_000);
37+
38+
const res = rateLimit('test-key', 1, 60_000);
39+
expect(res.allowed).toBe(true);
40+
41+
vi.useRealTimers();
42+
});
43+
});
44+
45+
describe('rateLimitDual', () => {
46+
beforeEach(() => {
47+
resetRateLimits();
48+
});
49+
50+
it('allows request when both IP and target limits are under thresholds', async () => {
51+
const res = await rateLimitDual('signin', 'user@example.com', 20, 5, 60_000);
52+
expect(res.allowed).toBe(true);
53+
});
54+
55+
it('blocks request when target limit is exceeded', async () => {
56+
for (let i = 0; i < 5; i++) {
57+
await rateLimitDual('signin', 'target@example.com', 20, 5, 60_000);
58+
}
59+
60+
const res = await rateLimitDual('signin', 'target@example.com', 20, 5, 60_000);
61+
expect(res.allowed).toBe(false);
62+
});
63+
64+
it('blocks IP when IP limit is exceeded before checking target limit', async () => {
65+
// Fill IP limit (3)
66+
for (let i = 0; i < 3; i++) {
67+
await rateLimitDual('signin', `victim${i}@example.com`, 3, 5, 60_000);
68+
}
69+
70+
// IP blocked attempt against a new target
71+
const res = await rateLimitDual('signin', 'fresh-target@example.com', 3, 5, 60_000);
72+
expect(res.allowed).toBe(false);
73+
74+
// Ensure fresh-target bucket was NOT populated/polluted because IP limit blocked it first
75+
resetRateLimits(); // Clear rate limits (e.g., simulating IP change or reset)
76+
const freshRes = await rateLimitDual('signin', 'fresh-target@example.com', 3, 5, 60_000);
77+
expect(freshRes.allowed).toBe(true);
78+
});
79+
});

‎src/lib/rate-limit.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import 'server-only';
11+
import { headers } from 'next/headers';
1112

1213
const buckets = new Map<string, number[]>();
1314

@@ -17,6 +18,32 @@ export interface RateLimitResult {
1718
retryAfterSeconds: number;
1819
}
1920

21+
/**
22+
* Reset all rate limiting buckets (useful for unit testing).
23+
*/
24+
export function resetRateLimits(): void {
25+
buckets.clear();
26+
}
27+
28+
/**
29+
* Safely extract client IP from Next.js headers.
30+
*/
31+
export async function getClientIp(): Promise<string> {
32+
try {
33+
const headerStore = await headers();
34+
const xff = headerStore.get('x-forwarded-for');
35+
if (xff) {
36+
const ip = xff.split(',')[0]?.trim();
37+
if (ip) return ip;
38+
}
39+
const realIp = headerStore.get('x-real-ip');
40+
if (realIp) return realIp.trim();
41+
} catch {
42+
// Outside request context (e.g. in tests without request context)
43+
}
44+
return '127.0.0.1';
45+
}
46+
2047
/**
2148
* Record a hit for `key` and report whether it stays within `limit` hits
2249
* per `windowMs`. Denied hits are not recorded (a blocked attacker doesn't
@@ -47,3 +74,24 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR
4774

4875
return { allowed: true, retryAfterSeconds: 0 };
4976
}
77+
78+
/**
79+
* Dual rate limiting: Checks IP first to block brute force / DoS before
80+
* checking target identifier (e.g. email) bucket, preventing Account Lockout DoS.
81+
*/
82+
export async function rateLimitDual(
83+
actionPrefix: string,
84+
targetIdentifier: string,
85+
ipLimit = 20,
86+
targetLimit = 5,
87+
windowMs = 60_000,
88+
): Promise<RateLimitResult> {
89+
const ip = await getClientIp();
90+
// IP rate limiting executed FIRST to prevent blocked IP from polluting target bucket
91+
const ipResult = rateLimit(`ip:${actionPrefix}:${ip}`, ipLimit, windowMs);
92+
if (!ipResult.allowed) {
93+
return ipResult;
94+
}
95+
96+
return rateLimit(`target:${actionPrefix}:${targetIdentifier.toLowerCase()}`, targetLimit, windowMs);
97+
}

0 commit comments

Comments
 (0)