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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
fetch-depth: 0

- uses: pnpm/action-setup@v6
with:
version: 11.14.0

- uses: actions/setup-node@v7
with:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/sentry-alert.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
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"
}
10 changes: 5 additions & 5 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, 5, 60_000);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
}
Expand Down Expand Up @@ -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 — the password verification is
// exactly what an attacker would use to burn CPU and event loop.
const rl = await rateLimitDual('signin', data.email, 5, 60_000);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
}
Expand Down
84 changes: 84 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { rateLimit, rateLimitDual, resetRateLimits } from '@/lib/rate-limit';

const mockGetHeader = vi.fn();

vi.mock('next/headers', () => ({
headers: async () => ({
get: (key: string) => mockGetHeader(key),
}),
}));

describe('rate-limit.ts', () => {
beforeEach(() => {
resetRateLimits();
mockGetHeader.mockReset();
});

it('rateLimit allows up to limit and blocks subsequent requests', () => {
for (let i = 0; i < 5; i++) {
expect(rateLimit('key1', 5, 60_000).allowed).toBe(true);
}
const blocked = rateLimit('key1', 5, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
});

it('rateLimitDual enforces IP rate limit first', async () => {
mockGetHeader.mockImplementation((key: string) => {
if (key === 'x-forwarded-for') return '203.0.113.195';
return null;
});

// 5 attempts from IP 203.0.113.195 for target target1@example.com
for (let i = 0; i < 5; i++) {
const res = await rateLimitDual('login', 'target1@example.com', 5, 60_000);
expect(res.allowed).toBe(true);
}

// 6th attempt from same IP for target1 should fail on IP limit
const blockedSameTarget = await rateLimitDual('login', 'target1@example.com', 5, 60_000);
expect(blockedSameTarget.allowed).toBe(false);

// 7th attempt from same IP for target2 MUST ALSO FAIL on IP limit without polluting target2
const blockedDiffTarget = await rateLimitDual('login', 'target2@example.com', 5, 60_000);
expect(blockedDiffTarget.allowed).toBe(false);
});

it('rateLimitDual enforces target rate limit across different IPs', async () => {
let currentIp = '1.1.1.1';
mockGetHeader.mockImplementation((key: string) => {
if (key === 'x-forwarded-for') return currentIp;
return null;
});

for (let i = 1; i <= 5; i++) {
currentIp = `10.0.0.${i}`;
const res = await rateLimitDual('login', 'victim@example.com', 5, 60_000);
expect(res.allowed).toBe(true);
}

// 6th attempt for victim@example.com from a new IP should fail target check
currentIp = '10.0.0.99';
const blocked = await rateLimitDual('login', 'victim@example.com', 5, 60_000);
expect(blocked.allowed).toBe(false);
});

it('rateLimitDual correctly parses x-forwarded-for with multiple IPs or falls back to x-real-ip', async () => {
mockGetHeader.mockImplementation((key: string) => {
if (key === 'x-forwarded-for') return '198.51.100.1, 10.0.0.1, 10.0.0.2';
return null;
});

const res = await rateLimitDual('test', 'user@example.com', 5, 60_000);
expect(res.allowed).toBe(true);

mockGetHeader.mockImplementation((key: string) => {
if (key === 'x-real-ip') return '198.51.100.2';
return null;
});

const res2 = await rateLimitDual('test', 'user@example.com', 5, 60_000);
expect(res2.allowed).toBe(true);
});
});
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 All @@ -17,6 +18,13 @@ export interface RateLimitResult {
retryAfterSeconds: number;
}

/**
* Reset all rate limit buckets (useful for unit testing).
*/
export function resetRateLimits(): void {
buckets.clear();
}

/**
* 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
Expand Down Expand Up @@ -47,3 +55,35 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR

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

/**
* Dual rate limiting by IP first, then target identifier (e.g. email).
* Performing IP check first prevents malicious IP-blocked actors from polluting target-based
* buckets and causing account lockout DoS for legitimate users.
*/
export async function rateLimitDual(
actionPrefix: string,
targetKey: string,
limit = 5,
windowMs = 60_000,
): Promise<RateLimitResult> {
let clientIp = 'unknown-ip';
try {
const h = await headers();
const xff = h.get('x-forwarded-for');
const xri = h.get('x-real-ip');
const firstXff = xff ? xff.split(',')[0] : null;
clientIp = (firstXff ? firstXff.trim() : null) || xri || 'unknown-ip';
} catch {
// If headers() is unavailable or throws, fallback to unknown-ip
}

// 1. IP rate limit check FIRST
const ipResult = rateLimit(`${actionPrefix}:ip:${clientIp}`, limit, windowMs);
if (!ipResult.allowed) {
return ipResult;
}

// 2. Target rate limit check SECOND
return rateLimit(`${actionPrefix}:target:${targetKey.toLowerCase()}`, limit, windowMs);
}