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
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
8 changes: 5 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,8 @@ import {
// ---------------------------------------------------------------------------

export const signUpAction = createSafeAction(signUpSchema, async (data) => {
const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000);
// Dual rate limiting: limit IP to 10 attempts and the target email to 5 attempts per windowMs.
const rl = await rateLimitDual(data.email, 10, 5, 60_000);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
}
Expand Down Expand Up @@ -125,7 +126,8 @@ 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);
// Dual rate limiting: limit IP to 10 attempts and the target email to 5 attempts per windowMs.
const rl = await rateLimitDual(data.email, 10, 5, 60_000);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
}
Expand Down
163 changes: 163 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

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

import { rateLimit, rateLimitDual } from '../rate-limit';
import { headers } from 'next/headers';

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

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

describe('rateLimit', () => {
it('allows hits up to limit within window', () => {
const key = 'test-key-1';
// Limit 3
expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 });
expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 });
expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 });
// 4th hit blocked
const res = rateLimit(key, 3, 60_000);
expect(res.allowed).toBe(false);
expect(res.retryAfterSeconds).toBe(60);
});

it('sliding window lets older hits fall out and allows new hits', () => {
const key = 'test-key-2';
// 3 hits at t=0
rateLimit(key, 3, 60_000);
rateLimit(key, 3, 60_000);
rateLimit(key, 3, 60_000);

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

// Advance time by 30 seconds
vi.advanceTimersByTime(30_000);
expect(rateLimit(key, 3, 60_000).allowed).toBe(false);

// Advance by another 31 seconds (total 61s from start)
vi.advanceTimersByTime(31_000);
// Older hits should have fallen out
expect(rateLimit(key, 3, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 });
});

it('opportunistically cleans up map when it grows unbounded', () => {
// Create over 10,000 buckets
// Fill the Map buckets with old timestamps
const now = Date.now();
const cutoff = now - 60_000;

// We can trigger cleanup by exceeding 10,000 bucket size.
// Let's call rateLimit with 10,005 unique keys.
for (let i = 0; i < 10005; i++) {
rateLimit(`cleanup-key-${i}`, 5, 60_000);
}

// Now let's advance time by 61 seconds so that all of them are considered expired.
vi.advanceTimersByTime(61_000);

// Call rateLimit one more time to trigger cleanup.
// This should clean up all the older keys because all of them are <= cutoff.
rateLimit('trigger-cleanup', 5, 60_000);

// To verify cleanup happened, if we check again, it shouldn't hit memory limits or map bounds.
// The map size has been significantly reduced internally.
// Let's assert that a new key is allowed.
expect(rateLimit('new-key', 5, 60_000)).toEqual({ allowed: true, retryAfterSeconds: 0 });
});
});

describe('rateLimitDual', () => {
it('extracts IP from x-forwarded-for first IP and limits requests', async () => {
const mockHeaders = {
get: vi.fn((name: string) => {
if (name === 'x-forwarded-for') return '192.168.1.100, 10.0.0.1';
return null;
}),
};
(headers as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(mockHeaders);

// Call it 3 times with same email and IP
// IP limit = 2, Email limit = 3
const email = 'user1@example.com';
const r1 = await rateLimitDual(email, 2, 3, 60_000);
expect(r1.allowed).toBe(true);

const r2 = await rateLimitDual(email, 2, 3, 60_000);
expect(r2.allowed).toBe(true);

// Third call should be blocked by IP limit
const r3 = await rateLimitDual(email, 2, 3, 60_000);
expect(r3.allowed).toBe(false);
expect(r3.retryAfterSeconds).toBe(60);
});

it('extracts IP from x-real-ip if x-forwarded-for is missing', async () => {
const mockHeaders = {
get: vi.fn((name: string) => {
if (name === 'x-real-ip') return '203.0.113.1';
return null;
}),
};
(headers as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(mockHeaders);

// IP limit = 1, Email limit = 2
const email = 'user2@example.com';
const r1 = await rateLimitDual(email, 1, 2, 60_000);
expect(r1.allowed).toBe(true);

const r2 = await rateLimitDual(email, 1, 2, 60_000);
expect(r2.allowed).toBe(false);
});

it('defaults to unknown-ip if both headers are missing', async () => {
const mockHeaders = {
get: vi.fn(() => null),
};
(headers as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(mockHeaders);

// IP limit = 1, Email limit = 2
const email = 'user3@example.com';
const r1 = await rateLimitDual(email, 1, 2, 60_000);
expect(r1.allowed).toBe(true);

const r2 = await rateLimitDual(email, 1, 2, 60_000);
expect(r2.allowed).toBe(false);
});

it('blocks on email rate limit even if IP limit is not reached', async () => {
// Let's mock a scenario where same email is targeted from different IPs (credential stuffing)
let currentIp = '1.1.1.1';
const mockHeaders = {
get: vi.fn((name: string) => {
if (name === 'x-real-ip') return currentIp;
return null;
}),
};
(headers as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(mockHeaders);

const email = 'target@example.com';
// IP limit = 5, Email limit = 2
// Hit 1 from IP 1.1.1.1
currentIp = '1.1.1.1';
expect((await rateLimitDual(email, 5, 2, 60_000)).allowed).toBe(true);

// Hit 2 from IP 2.2.2.2
currentIp = '2.2.2.2';
expect((await rateLimitDual(email, 5, 2, 60_000)).allowed).toBe(true);

// Hit 3 from IP 3.3.3.3 - should block on email limit (2)
currentIp = '3.3.3.3';
const res = await rateLimitDual(email, 5, 2, 60_000);
expect(res.allowed).toBe(false);
});
});
});
32 changes: 32 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 @@ -22,6 +23,37 @@ export interface RateLimitResult {
* per `windowMs`. Denied hits are not recorded (a blocked attacker doesn't
* extend their own lockout window).
*/
/**
* Dual rate limiter combining IP-based and email-based limits.
* Protects against credential stuffing and brute-force attacks by limiting
* requests per IP address AND per target email account.
*/
export async function rateLimitDual(
email: string,
ipLimit = 10,
emailLimit = 5,
windowMs = 60_000,
): 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') ?? 'unknown-ip';
Comment on lines +38 to +39

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)rate-limit\.ts$|docs/voice-guide\.md|src/lib/logger\.ts|next\.config|middleware|vercel|fly|docker|nginx|traefik|caddy|hono|aws|render' || true

echo
echo "rate-limit outline and contents:"
if [ -f src/lib/rate-limit.ts ]; then
  wc -l src/lib/rate-limit.ts
  cat -n src/lib/rate-limit.ts
fi

echo
echo "Search for rate limiter usage / sign-up action:"
rg -n "rateLimitDual|rate-limit|rateLimit|isRateLimited|signUpAction|createAccount|password" src || true

echo
echo "Deployment/config header clues:"
rg -n "x-forwarded-for|x-real-ip|x-forwarded|headers|i?forward|trusted|proxy|CF-Connecting-IP|X-Real-IP|fastly-client-ip|forwarded" . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' | head -200 || true

Repository: projectamazonph/amph-v2

Length of output: 27794


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "middleware.ts:"
cat -n src/middleware.ts

echo
echo "rate-limit tests:"
cat -n src/lib/__tests__/rate-limit.test.ts | sed -n '70,155p'

echo
echo "auth action and tests around signUp:"
cat -n src/app/actions/auth.ts | sed -n '1,110p'
cat -n src/app/actions/__tests__/auth-actions.test.ts | sed -n '60,135p'

echo
echo "read-only verifier: rateLimitDual keyed by spoofed first x-forwarded-for value"
node - <<'JS'
let buckets = new Map();
function rateLimit(key, limit = 5, windowMs = 60000) {
  const now = Date.now();
  const cutoff = now - windowMs;
  const hits = (buckets.get(key) ?? []).filter((t) => t > cutoff);
  if (hits.length >= limit) {
    buckets.set(key, hits);
    return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((hits[0] + windowMs - now) / 1000)) };
  }
  hits.push(now);
  buckets.set(key, hits);
  return { allowed: true, retryAfterSeconds: 0 };
}

function ipFromHeaders(headers) {
  const xff = headers.get('x-forwarded-for');
  return (xff ? xff.split(',')[0]?.trim() : null) ?? headers.get('x-real-ip') ?? 'unknown-ip';
}

function rateLimitDual(headers, email, ipLimit = 10) {
  const ip = ipFromHeaders(headers);
  const ipRes = rateLimit(`ip:${ip}`, ipLimit, 60000);
  if (!ipRes.allowed) return { allowed: false, key: `ip:${ip}`, emailRejected: false };
  return { allowed: true, key: `ip:${ip}` };
}

const email = 'target@example.com';
let allowed = 0;
for (const spoofedIp of ['0.0.0.0', '10.20.30.40', '192.168.255.255', '8.8.8.8']) {
  const result = rateLimitDual(
    { get: (name) => name === 'x-forwarded-for' ? spoofedIp : null },
    email,
    1
  );
  if (result.allowed) allowed++;
}
console.log(JSON.stringify({
  allowedAcrossSpoofedXffValues: allowed,
  firstIp: ipFromHeaders({ get: () => '8.8.8.8, 7.7.7.7, 1.2.3.4' }),
  firstIpAfterSpace: ipFromHeaders({ get: () => ' 1.2.3.4 , 8.8.8.8' }),
  fallbackToRealIp: ipFromHeaders({ get: () => null }),
}));
JS

Repository: projectamazonph/amph-v2

Length of output: 17651


Do not use client-controlled forwarded headers as the IP key.

In this deployment path, x-forwarded-for and x-real-ip are not rewritten by middleware, so signUpAction maps each spoofed first x-forwarded-for value to a different ip:* bucket and can bypass the 10-request IP limit before hashPassword runs. Derive the key from a trusted proxy/platform signal and add a spoofed-header regression test.

🤖 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 38 - 39, Update the IP-key derivation
used by signUpAction to avoid trusting x-forwarded-for or x-real-ip; use the
deployment’s trusted proxy/platform client-IP signal instead, while preserving
the unknown-IP fallback. Add a regression test proving spoofed forwarded headers
cannot create separate rate-limit buckets or bypass the 10-request limit before
hashPassword.

const lowercaseEmail = email.toLowerCase();

// First, check and record the IP limit
const ipRes = rateLimit(`ip:${ip}`, ipLimit, windowMs);
if (!ipRes.allowed) {
return ipRes;
}

// Next, check and record the target email limit
const emailRes = rateLimit(`email:${lowercaseEmail}`, emailLimit, windowMs);
if (!emailRes.allowed) {
return emailRes;
}

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

export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitResult {
const now = Date.now();
const cutoff = now - windowMs;
Expand Down