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-31 - Dual Target/IP Rate Limiting for Authentication Server Actions
**Vulnerability:** The application originally rate-limited login and registration server actions purely by email. An attacker performing a distributed credential stuffing or dictionary attack targeting many distinct email addresses could bypass single-email rate limits, or spoof/rotate email addresses to spam the endpoint and abuse backend/database resources.
**Learning:** Purely target-based rate limits (like email) do not prevent distributed brute-force or dictionary attacks across multiple targets. Rate limits should utilize dual indicators (combining both target-based keys like lowercase emails and IP-based keys using headers like X-Forwarded-For or X-Real-IP) to defend in depth against multi-target attacks from single IP addresses.
**Prevention:** Implement a dual-rate limiter that checks target identifiers first, then extracts client IP addresses securely (parsing the first entry of the X-Forwarded-For header or falling back to X-Real-IP), and rate-limits the IP address independently.
3 changes: 3 additions & 0 deletions src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { vi } from 'vitest';
vi.mock('server-only', () => ({}));

vi.mock('next/headers', () => ({
headers: () => Promise.resolve({
get: () => null,
}),
cookies: () => ({
get: () => undefined,
set: vi.fn(),
Expand Down
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 @@ -24,6 +24,9 @@ vi.mock('@/lib/db', () => ({
}));

vi.mock('next/headers', () => ({
headers: () => Promise.resolve({
get: () => null,
}),
cookies: () => ({
get: () => undefined,
set: vi.fn(),
Expand Down
3 changes: 3 additions & 0 deletions src/app/actions/__tests__/progress-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ vi.mock('@/lib/auth', () => ({
}));

vi.mock('next/headers', () => ({
headers: () => Promise.resolve({
get: () => null,
}),
cookies: () => ({
get: () => undefined,
set: vi.fn(),
Expand Down
3 changes: 3 additions & 0 deletions src/app/actions/__tests__/tool-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ vi.mock('@/lib/db', () => ({
}))

vi.mock('next/headers', () => ({
headers: () => Promise.resolve({
get: () => null,
}),
cookies: () => ({
get: () => undefined,
set: vi.fn(),
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 { rateLimit, 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, 5, 20, 60_000);
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, 5, 20, 60_000);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
}
Expand Down
108 changes: 108 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { rateLimit, rateLimitDual } from '@/lib/rate-limit';
import { headers } from 'next/headers';

vi.mock('next/headers', () => ({
headers: vi.fn(),
cookies: vi.fn(),
}));

describe('rate-limit.ts', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

describe('rateLimit helper', () => {
it('allows requests up to the limit', () => {
const key = 'test-limit-1';
for (let i = 0; i < 5; i++) {
const result = rateLimit(key, 5, 60_000);
expect(result.allowed).toBe(true);
expect(result.retryAfterSeconds).toBe(0);
}

// 6th request is blocked
const blockedResult = rateLimit(key, 5, 60_000);
expect(blockedResult.allowed).toBe(false);
expect(blockedResult.retryAfterSeconds).toBeGreaterThan(0);
});

it('clears expired requests from the sliding window', () => {
const key = 'test-limit-2';
// Use 3 requests limit
expect(rateLimit(key, 3, 60_000).allowed).toBe(true);
expect(rateLimit(key, 3, 60_000).allowed).toBe(true);

// Move time forward by 30 seconds
vi.advanceTimersByTime(30_000);

expect(rateLimit(key, 3, 60_000).allowed).toBe(true);

// At this point we have 3 requests in the window. 4th is blocked.
expect(rateLimit(key, 3, 60_000).allowed).toBe(false);

// Advance by another 30.1 seconds (total 60.1s).
// The first two requests (made at t=0) fall out of the 60s window.
vi.advanceTimersByTime(30100);

// Now allowed again
const result = rateLimit(key, 3, 60_000);
expect(result.allowed).toBe(true);
});
});

describe('rateLimitDual helper', () => {
it('bypasses IP check gracefully when headers throws', async () => {
(headers as any).mockImplementation(() => {
throw new Error('Not available');
});

// Email limit is 2
expect((await rateLimitDual('act', 'test@test.com', 2, 2, 60_000)).allowed).toBe(true);
expect((await rateLimitDual('act', 'test@test.com', 2, 2, 60_000)).allowed).toBe(true);
// 3rd email limit blocked
expect((await rateLimitDual('act', 'test@test.com', 2, 2, 60_000)).allowed).toBe(false);
});

it('extracts IP from x-forwarded-for first IP and rate-limits by IP', async () => {
const mockHeaders = new Map<string, string>();
mockHeaders.set('x-forwarded-for', '203.0.113.195, 70.41.3.18, 150.172.238.178');

(headers as any).mockResolvedValue({
get: (key: string) => mockHeaders.get(key) || null,
});

// Limit per IP is 2. We use different emails so we only trigger IP limit.
const result1 = await rateLimitDual('act-ip', 'user1@test.com', 5, 2, 60_000);
expect(result1.allowed).toBe(true);

const result2 = await rateLimitDual('act-ip', 'user2@test.com', 5, 2, 60_000);
expect(result2.allowed).toBe(true);

// 3rd request from same IP is blocked
const result3 = await rateLimitDual('act-ip', 'user3@test.com', 5, 2, 60_000);
expect(result3.allowed).toBe(false);
});

it('falls back to x-real-ip when x-forwarded-for is missing', async () => {
const mockHeaders = new Map<string, string>();
mockHeaders.set('x-real-ip', '198.51.100.1');

(headers as any).mockResolvedValue({
get: (key: string) => mockHeaders.get(key) || null,
});

// IP limit is 1
const result1 = await rateLimitDual('act-real', 'u1@test.com', 5, 1, 60_000);
expect(result1.allowed).toBe(true);

const result2 = await rateLimitDual('act-real', 'u2@test.com', 5, 1, 60_000);
expect(result2.allowed).toBe(false);
});
});
});
40 changes: 40 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,42 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR

return { allowed: true, retryAfterSeconds: 0 };
}

/**
* Dual rate-limiter: limits by both target key (email) and client IP address.
* Prevents credential-stuffing/brute-force attacks across different emails from a single IP,
* and standard brute-force on a single email.
*/
export async function rateLimitDual(
action: string,
email: string,
emailLimit = 5,
ipLimit = 20,
windowMs = 60_000
): Promise<RateLimitResult> {
const emailKey = `${action}:email:${email.toLowerCase()}`;
const emailRl = rateLimit(emailKey, emailLimit, windowMs);
if (!emailRl.allowed) {
return emailRl;
}

try {
const heads = await headers();
const xff = heads.get('x-forwarded-for');
// Safely extract first IP from X-Forwarded-For if present, fallback to X-Real-IP
const ip = xff ? xff.split(',')[0]?.trim() : heads.get('x-real-ip');

if (ip) {
const ipKey = `${action}:ip:${ip}`;
const ipRl = rateLimit(ipKey, ipLimit, windowMs);
if (!ipRl.allowed) {
return ipRl;
}
}
} catch {
// In environments (such as static build or server component rendering without request context)
// where headers() is not available or throws, we bypass the IP check gracefully.
}

return { allowed: true, retryAfterSeconds: 0 };
}