Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@
"eslint --fix"
]
},
"packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a"
"packageManager": "pnpm@11.14.0"
}
3 changes: 3 additions & 0 deletions src/app/actions/__tests__/auth-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ vi.mock('next/headers', () => ({
set: vi.fn(),
delete: vi.fn(),
}),
headers: () => Promise.resolve({
get: () => null,
}),
}));

vi.mock('next/navigation', () => ({
Expand Down
6 changes: 3 additions & 3 deletions src/app/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.`);
}
Expand Down Expand Up @@ -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.`);
}
Expand Down
153 changes: 153 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { rateLimitDual, resetRateLimits } from '@/lib/rate-limit';

const mockHeadersMap = new Map<string, string>();

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);
Comment on lines +126 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

r8 is blocked by the IP limit, not the target limit.

The test sets ipLimit: 5 for requests from 5.6.7.8. Requests r4 through r7 consume 4 IP hits, and r8 consumes the fifth check against a bucket that already holds 4... The count reaches the IP threshold at the same request that reaches the target threshold. The assertion passes, but it does not prove that targetLimit caused the block. The comment on Line 132 claims a target-limit block.

Raise ipLimit for the later requests so only the target limit can fire.

πŸ’š Proposed fix to isolate the target limit
     // 6th target hit overall: should be blocked by targetLimit
     const r8 = await rateLimitDual('login', 'user@example.com', {
-      ipLimit: 5,
+      ipLimit: 50, // high enough that only targetLimit can block
       targetLimit: 5,
     });
     expect(r8.allowed).toBe(false);
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
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: 50, // high enough that only targetLimit can block
targetLimit: 5,
});
expect(r8.allowed).toBe(false);
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/__tests__/rate-limit.test.ts` around lines 126 - 137, Update the
later rateLimitDual calls for r7 and r8 in the test so their ipLimit is higher
than the accumulated requests from 5.6.7.8, while keeping targetLimit at 5. This
isolates the target-limit behavior and ensures r8 is blocked specifically by
targetLimit.

});

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);
});
});
41 changes: 41 additions & 0 deletions src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import 'server-only';
import { headers } from 'next/headers';

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

Expand Down Expand Up @@ -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<RateLimitResult> {
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';
Comment on lines +78 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”’ Security & Privacy | 🟠 Major | ⚑ Quick win

Harden the client IP resolution.

Three concerns in the IP derivation on Line 80:

  1. x-forwarded-for is client controllable. If the deployment does not sit behind a proxy that overwrites this header, an attacker sets a new value on each request and bypasses the IP bucket completely. Read the IP from a trusted source (platform-provided IP header, or take the entry at a known trusted-proxy offset from the right).
  2. An input like ',' produces an empty string. '' is not nullish, so the ?? chain does not fall through to x-real-ip, and every such request shares the bucket key ip:<action>:.
  3. The '127.0.0.1' fallback also merges all header-less requests into one bucket. With ipLimit = 10 per minute, a deployment that loses these headers rate-limits all users together.

Normalize to null on empty values, and consider failing closed or logging when no IP can be resolved.

πŸ”’ Proposed fix for parsing and fallback
-  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 heads = await headers();
+  const forwardedFor = heads.get('x-forwarded-for')?.split(',')[0]?.trim();
+  const realIp = heads.get('x-real-ip')?.trim();
+  // Empty strings must not win the fallback chain, otherwise unrelated
+  // requests collapse into a single shared bucket.
+  const ip = forwardedFor || realIp || 'unknown';
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/rate-limit.ts` around lines 78 - 80, Harden IP resolution in the
rate-limit flow around headers() by using only a trusted platform IP header or a
configured trusted-proxy offset from x-forwarded-for; do not trust the first
client-controlled entry by default. Normalize trimmed empty values to null,
allow fallback to x-real-ip, and remove the shared 127.0.0.1 fallback by failing
closed or applying the established no-IP handling/logging path when no address
is available.


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);
}