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-08-06 - Test Pollution from Persistent In-Memory Rate Limit Buckets
**Vulnerability:** When introducing asynchronous dual rate-limiting (`rateLimitDual`) to critical Server Actions, in-memory sliding-window buckets persisted across test executions. This caused sequential unit tests using identical email/identifier payloads to exceed rate limits and fail unexpectedly.
**Learning:** In Next.js Server Actions, in-memory rate limiters maintain static module-scope state. In a testing suite, successive actions on the same entity or test user will inadvertently trigger rate limit blocks.
**Prevention:** Always export a state reset helper (e.g., `resetRateLimits()`) to clear rate-limiting maps in `beforeEach` hooks, ensuring complete isolation between test runs and preventing test pollution.
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@9.15.4"
}
3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
packages:
- '.'

onlyBuiltDependencies:
- "@prisma/client"
- "@prisma/engines"
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ vi.mock('next/headers', () => ({
set: vi.fn(),
delete: vi.fn(),
}),
headers: () => Promise.resolve({ get: () => null }),
}));

vi.mock('next/navigation', () => ({
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 @@ -29,17 +29,20 @@ vi.mock('next/headers', () => ({
set: vi.fn(),
delete: vi.fn(),
}),
headers: () => Promise.resolve({ get: () => null }),
}));

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

import { db } from '@/lib/db';
import { resetRateLimits } from '@/lib/rate-limit';

describe('auth actions', () => {
beforeEach(() => {
vi.resetAllMocks();
resetRateLimits();
mockSignToken.mockResolvedValue('token');
});

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.toLowerCase()}`, 5, 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.toLowerCase()}`, 5, 60_000);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
}
Expand Down
158 changes: 158 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const mockHeadersGet = vi.fn<(key: string) => string | null>(() => null);

vi.mock('next/headers', () => ({
headers: () => Promise.resolve({
get: (key: string) => mockHeadersGet(key),
}),
}));

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

describe('rate-limit.ts', () => {
beforeEach(() => {
mockHeadersGet.mockReset();
vi.useFakeTimers();
});
Comment on lines +1 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reset rate-limit state before each test.

buckets persists across test cases. Import resetRateLimits and call it in beforeEach so each test starts with isolated limiter state.

🤖 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 1 - 17, Import
resetRateLimits alongside rateLimit and rateLimitDual, then invoke
resetRateLimits in the existing beforeEach setup so every test starts with
isolated rate-limit bucket state.


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

describe('rateLimit', () => {
it('allows hits under the limit and blocks exceeding hits within the window', () => {
const key = 'test:rateLimit';

// 5 allowed hits
for (let i = 0; i < 5; i++) {
const res = rateLimit(key, 5, 60_000);
expect(res.allowed).toBe(true);
expect(res.retryAfterSeconds).toBe(0);
}

// 6th hit blocked
const blockedRes = rateLimit(key, 5, 60_000);
expect(blockedRes.allowed).toBe(false);
expect(blockedRes.retryAfterSeconds).toBe(60);

// Advance time by 30 seconds, still blocked
vi.advanceTimersByTime(30_000);
const blockedRes2 = rateLimit(key, 5, 60_000);
expect(blockedRes2.allowed).toBe(false);
expect(blockedRes2.retryAfterSeconds).toBe(30);

// Advance time by another 31 seconds, oldest hit falls out of window
vi.advanceTimersByTime(31_000);
const allowedRes = rateLimit(key, 5, 60_000);
expect(allowedRes.allowed).toBe(true);
});

it('does not record blocked hits so retryAfterSeconds does not get extended indefinitely', () => {
const key = 'test:blocked';

// Burn limit
for (let i = 0; i < 5; i++) {
rateLimit(key, 5, 60_000);
}

// Advance 50 seconds
vi.advanceTimersByTime(50_000);

// Blocked hit
const res1 = rateLimit(key, 5, 60_000);
expect(res1.allowed).toBe(false);

// Advance 11 seconds (total 61s from start). The original hits expired, and blocked hits shouldn't count.
vi.advanceTimersByTime(11_000);
const res2 = rateLimit(key, 5, 60_000);
expect(res2.allowed).toBe(true);
});

it('performs opportunistic cleanup when buckets map size exceeds 10,000', () => {
// Fill buckets past 10,000 with expired entries
for (let i = 0; i < 10005; i++) {
rateLimit(`cleanup:${i}`, 5, 60_000);
}
// Advance time past windowMs
vi.advanceTimersByTime(65_000);
// Trigger cleanup
rateLimit('trigger-cleanup', 5, 60_000);

// The cleanup should have run. Check that adding to cleanup:0 is fresh
const res = rateLimit('cleanup:0', 5, 60_000);
expect(res.allowed).toBe(true);
});
});

describe('rateLimitDual', () => {
it('uses x-forwarded-for header if present (trimmed first item)', async () => {
mockHeadersGet.mockImplementation((key) => {
if (key === 'x-forwarded-for') return ' 1.2.3.4, 5.6.7.8 ';
return null;
});

const res = await rateLimitDual('target:1', 1, 60_000);
expect(res.allowed).toBe(true);

// Check that IP bucket for 1.2.3.4 is populated and blocks
mockHeadersGet.mockImplementation((key) => {
if (key === 'x-forwarded-for') return '1.2.3.4';
return null;
});
const blockedRes = await rateLimitDual('target:other', 1, 60_000);
expect(blockedRes.allowed).toBe(false); // IP 1.2.3.4 blocked
});

it('falls back to x-real-ip if x-forwarded-for is absent', async () => {
mockHeadersGet.mockImplementation((key) => {
if (key === 'x-real-ip') return '8.8.8.8';
return null;
});

const res = await rateLimitDual('target:2', 1, 60_000);
expect(res.allowed).toBe(true);

// Blocked on real IP
const blockedRes = await rateLimitDual('target:other', 1, 60_000);
expect(blockedRes.allowed).toBe(false);
});

it('falls back to unknown if both headers are absent', async () => {
mockHeadersGet.mockReturnValue(null);

const res = await rateLimitDual('target:3', 1, 60_000);
expect(res.allowed).toBe(true);

// Blocked on unknown IP
const blockedRes = await rateLimitDual('target:other', 1, 60_000);
expect(blockedRes.allowed).toBe(false);
});

it('blocks IP first and does not pollute target bucket', async () => {
// Set IP
mockHeadersGet.mockImplementation((key) => {
if (key === 'x-forwarded-for') return '9.9.9.9';
return null;
});

const target = 'target:pollute-test';

// Burn the IP rate limit with other targets
await rateLimitDual('dummy:1', 1, 60_000);

// Now IP is blocked. A hit with the target should fail at the IP layer first.
const blockedIpRes = await rateLimitDual(target, 1, 60_000);
expect(blockedIpRes.allowed).toBe(false);

// Now change the IP. The target limit shouldn't have been hit/polluted at all because the IP block prevented the target check.
mockHeadersGet.mockImplementation((key) => {
if (key === 'x-forwarded-for') return '10.10.10.10';
return null;
});

const allowedRes = await rateLimitDual(target, 1, 60_000);
expect(allowedRes.allowed).toBe(true); // target still has its allowance because target check was bypassed when IP was blocked
});
});
});
31 changes: 31 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,33 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR

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

/**
* Asynchronously dual rate-limit client IP first, then the target-based key.
* Prevents account lockout attacks by rejecting blocked IPs before updating target buckets.
*/
export async function rateLimitDual(
targetKey: string,
limit = 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';
Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect repository deployment configuration and documentation for forwarding-header handling.
fd -HI -t f . | rg '(next\.config|vercel\.json|Dockerfile|docker-compose|nginx|caddy|traefik|kubernetes|helm|README|docs)' \
  | while IFS= read -r file; do
      rg -n -i -C 3 'x-forwarded-for|x-real-ip|trusted proxy|proxy_set_header|real_ip_header' "$file" || true
    done

Repository: projectamazonph/amph-v2

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
fd -HI -t f 'rate-limit|rate-limit\.test|vercel\.json|next\.config|Dockerfile|docker-compose|.*nginx.*|.*caddy.*|.*traefik.*|.*kubernetes.*|.*helm.*|README|docs/voice-guide' . \
  | sed -n '1,120p'

echo "== src/lib/rate-limit.ts outline =="
ast-grep outline src/lib/rate-limit.ts 2>/dev/null || true

echo "== src/lib/rate-limit.ts relevant lines =="
cat -n src/lib/rate-limit.ts | sed -n '1,150p'

echo "== src/lib/__tests__/rate-limit.test.ts relevant lines =="
cat -n src/lib/__tests__/rate-limit.test.ts | sed -n '1,220p'

echo "== search for use of limitRequest/rate limiting helpers =="
rg -n "limitRequest|rateLimit|rate-limit|unknown|x-forwarded-for|x-real-ip" src package.json -S

Repository: projectamazonph/amph-v2

Length of output: 31800


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
fd -HI -t f 'rate-limit|rate-limit\.test|vercel\.json|next\.config|Dockerfile|docker-compose|.*nginx.*|.*caddy.*|.*traefik.*|.*kubernetes.*|.*helm.*|README|docs/voice-guide' . \
  | sed -n '1,120p'

echo "== rate-limit source excerpt =="
cat -n src/lib/rate-limit.ts | sed -n '1,150p'

echo "== rate-limit tests excerpt =="
cat -n src/lib/__tests__/rate-limit.test.ts | sed -n '1,220p'

echo "== usages/excerpts =="
rg -n "limitRequest|rateLimit|rate-limit|unknown|x-forwarded-for|x-real-ip" src package.json -S

Repository: projectamazonph/amph-v2

Length of output: 31357


Do not rate-limit missing-identity requests in a shared ip:unknown bucket.

When both x-forwarded-for and x-real-ip are absent, sign-in and sign-up requests use the same ip:unknown bucket. Five of those requests can block other authenticated sign-in/sign-up attempts to different targets for 60 seconds. Only apply the IP bucket for trusted ingress-supplied identities or add a separate per-target-first default path, with the first allowed hit counted; otherwise, skip the IP check for client-supplied/missing headers. Update the existing “falls back to unknown” tests to match the chosen default behavior.

Verify the deployment ingress replaces client-supplied x-forwarded-for; if it passes user-controlled values through, it can select the IP rate-limit bucket.

🤖 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 61 - 63, Update the IP identity handling
in the rate-limit flow so missing or untrusted client-supplied headers do not
share the ip:unknown bucket or select another user's bucket; apply the IP limit
only to trusted ingress-provided identities, or use the existing
per-target-first fallback with its first hit counted. Adjust the tests covering
the “unknown” fallback to assert the selected behavior, and verify the
deployment ingress replaces rather than forwards client-controlled
x-forwarded-for values.


// Always check and record IP limit first.
const ipRl = rateLimit(`ip:${ip}`, limit, windowMs);
if (!ipRl.allowed) {
return ipRl;
}

// Only check and record target limit if IP check passed.
return rateLimit(targetKey, limit, windowMs);
}

/**
* Resets all rate limit buckets (primarily for testing isolation).
*/
export function resetRateLimits(): void {
buckets.clear();
}