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-20 - Dual Rate-Limiting Protects Against Multi-Vector Brute-Force Attacks
**Vulnerability:** Authentication endpoints (`signInAction` and `signUpAction`) only rate-limited on a single target key (`signin:${email}`). This left the system vulnerable to distributed dictionary/credential stuffing attacks (same target from multiple IPs) and multi-target credential stuffing (multiple emails targeted from the same IP, bypassing target-based rate limits).
**Learning:** Single-key rate limiting is insufficient for modern high-value web applications. Attackers can distribute requests across thousands of accounts from a single IP to bypass target-locked throttling.
**Prevention:** Always implement dual rate-limiting combining both IP-based keys (retrieved safely via headers like `x-forwarded-for`/`x-real-ip`) and target-based keys (such as lowercase email addresses).
Comment on lines +8 to +11

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

Correct the old limiter description and define the attack terms.

Line 9 assigns the sign-in key to both actions, although sign-up used its own signup: key. Also define “credential stuffing” and avoid unexplained phrases such as “distributed dictionary attack.”

Proposed rewrite
-**Vulnerability:** Authentication endpoints (`signInAction` and `signUpAction`) only rate-limited on a single target key (`signin:${email}`). This left the system vulnerable to distributed dictionary/credential stuffing attacks (same target from multiple IPs) and multi-target credential stuffing (multiple emails targeted from the same IP, bypassing target-based rate limits).
-**Learning:** Single-key rate limiting is insufficient for modern high-value web applications. Attackers can distribute requests across thousands of accounts from a single IP to bypass target-locked throttling.
-**Prevention:** Always implement dual rate-limiting combining both IP-based keys (retrieved safely via headers like `x-forwarded-for`/`x-real-ip`) and target-based keys (such as lowercase email addresses).
+**Vulnerability:** Sign-in and sign-up limited repeated attempts for one email address only. An attacker could try one account from many IP addresses, or try many accounts from one IP address.
+**Learning:** Credential stuffing means trying leaked email and password pairs on many accounts. A limit for only one email address does not stop both attack paths.
+**Prevention:** Use two limits: one for the lowercase email address and one for a client IP address received from a trusted proxy.

As per coding guidelines, “Use direct, plain-spoken language for the Filipino VA audience, define jargon, and avoid generic AI-slop phrases.”

📝 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
## 2026-07-20 - Dual Rate-Limiting Protects Against Multi-Vector Brute-Force Attacks
**Vulnerability:** Authentication endpoints (`signInAction` and `signUpAction`) only rate-limited on a single target key (`signin:${email}`). This left the system vulnerable to distributed dictionary/credential stuffing attacks (same target from multiple IPs) and multi-target credential stuffing (multiple emails targeted from the same IP, bypassing target-based rate limits).
**Learning:** Single-key rate limiting is insufficient for modern high-value web applications. Attackers can distribute requests across thousands of accounts from a single IP to bypass target-locked throttling.
**Prevention:** Always implement dual rate-limiting combining both IP-based keys (retrieved safely via headers like `x-forwarded-for`/`x-real-ip`) and target-based keys (such as lowercase email addresses).
## 2026-07-20 - Dual Rate-Limiting Protects Against Multi-Vector Brute-Force Attacks
**Vulnerability:** Sign-in and sign-up limited repeated attempts for one email address only. An attacker could try one account from many IP addresses, or try many accounts from one IP address.
**Learning:** Credential stuffing means trying leaked email and password pairs on many accounts. A limit for only one email address does not stop both attack paths.
**Prevention:** Use two limits: one for the lowercase email address and one for a client IP address received from a trusted proxy.
🤖 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 @.jules/sentinel.md around lines 8 - 11, Update the “Dual Rate-Limiting”
entry in sentinel.md so it accurately states that signInAction uses
signin:${email} while signUpAction uses signup:${email}. Replace unexplained
“distributed dictionary attack” wording with plain language, and define
credential stuffing as using stolen username/password pairs against many
accounts; retain the explanation that IP-based and target-based limits are both
required.

Source: Coding guidelines

3 changes: 3 additions & 0 deletions src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ vi.mock('next/headers', () => ({
set: vi.fn(),
delete: vi.fn(),
}),
headers: () => Promise.resolve({
get: () => null,
}),
}));

vi.mock('next/navigation', () => ({
Expand Down
63 changes: 63 additions & 0 deletions src/app/actions/__tests__/auth-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ vi.mock('@/lib/db', () => ({
},
}));

const { mockHeaders } = vi.hoisted(() => ({
mockHeaders: {
get: vi.fn().mockReturnValue(null),
},
}));

vi.mock('next/headers', () => ({
cookies: () => ({
get: () => undefined,
set: vi.fn(),
delete: vi.fn(),
}),
headers: () => Promise.resolve(mockHeaders),
}));

vi.mock('next/navigation', () => ({
Expand Down Expand Up @@ -139,4 +146,60 @@ describe('auth actions', () => {
expect(result.success).toBe(true);
expect((result as any).data.ok).toBe(true);
});

describe('dual rate-limiting', () => {
beforeEach(() => {
mockHeaders.get.mockImplementation((header: string) => {
if (header === 'x-forwarded-for') return '203.0.113.195';
return null;
});
});

it('rate-limits by email on multiple sign-in attempts for same email', async () => {
(db.user.findUnique as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(null);

// Trigger rate limit with 5 allowed requests
for (let i = 0; i < 5; i++) {
const res = await signInAction({ email: 'target@example.com', password: 'x' });
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error).toBe('Email or password is incorrect.');
}
}

// 6th request triggers email rate limit
const blockedRes = await signInAction({ email: 'target@example.com', password: 'x' });
expect(blockedRes.success).toBe(false);
if (!blockedRes.success) {
expect(blockedRes.error).toMatch(/Too many attempts\. Try again in \d+s\./);
}
});

it('rate-limits by IP on multiple sign-in attempts from same IP but different emails', async () => {
(db.user.findUnique as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(null);

// Use a distinct IP
mockHeaders.get.mockImplementation((header: string) => {
if (header === 'x-forwarded-for') return '198.51.100.42';
return null;
});

// 10 attempts on different emails from same IP are allowed to check credentials (but fail credential check)
for (let i = 0; i < 10; i++) {
const email = `user-${i}@example.com`;
const res = await signInAction({ email, password: 'x' });
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error).toBe('Email or password is incorrect.');
}
}

// 11th request triggers IP rate limit
const blockedRes = await signInAction({ email: 'user-11@example.com', password: 'x' });
expect(blockedRes.success).toBe(false);
if (!blockedRes.success) {
expect(blockedRes.error).toBe('Too many attempts from this IP. Try again in 60s.');
}
});
});
Comment on lines +150 to +204

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 | 🟠 Major | ⚡ Quick win

Keep the auth action test beside auth.ts.

Move this suite to src/app/actions/auth.test.ts so the new throttling coverage is adjacent to its source.

As per coding guidelines, “Keep tests next to the code they test: foo.ts should have foo.test.ts.”

🤖 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/app/actions/__tests__/auth-actions.test.ts` around lines 150 - 204, Move
the dual rate-limiting test suite from the nested __tests__ location into
src/app/actions/auth.test.ts, keeping its existing tests and setup unchanged so
the coverage remains adjacent to the auth action implementation.

Source: Coding guidelines

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

vi.mock('next/navigation', () => ({
Expand Down
35 changes: 27 additions & 8 deletions src/app/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
'use server';

import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { db } from '@/lib/db';
import {
hashPassword,
Expand Down Expand Up @@ -32,9 +33,18 @@ import {
// ---------------------------------------------------------------------------

export const signUpAction = createSafeAction(signUpSchema, async (data) => {
const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
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';

const rlEmail = rateLimit(`signup:email:${data.email.toLowerCase()}`, 5, 60_000);
if (!rlEmail.allowed) {
throw new Error(`Too many attempts. Try again in ${rlEmail.retryAfterSeconds}s.`);
}

const rlIp = rateLimit(`signup:ip:${ip}`, 10, 60_000);
if (!rlIp.allowed) {
throw new Error(`Too many attempts from this IP. Try again in ${rlIp.retryAfterSeconds}s.`);
Comment on lines +36 to +47

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 '(^|/)auth\.ts$|rate(limit|limiter)?|src/app/actions' || true

echo "== auth outline =="
ast-grep outline src/app/actions/auth.ts || true

echo "== auth relevant lines =="
sed -n '1,190p' src/app/actions/auth.ts | cat -n

echo "== rate limiter references =="
rg -n "function rateLimit|const rateLimit|rateLimit|signup:email:|signup:ip:" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

Repository: projectamazonph/amph-v2

Length of output: 11369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rate limit implementation and tests =="
sed -n '1,180p' src/lib/rate-limit.ts | cat -n
sed -n '1,140p' src/lib/__tests__/rate-limit.test.ts | cat -n

echo "== auth tests around signup/signin =="
rg -n "signUpAction|signInAction|signup|signin|Too many attempts from this IP|Too many attempts" src/app/actions/__tests__ src/app/actions/__tests__/auth-actions.test.ts --context 3 || true

echo "== deployment/reverse proxy/origin direct access references =="
rg -n "x-forwarded-for|Forwarded|Client-IP|x-real-ip|VERCEL|HOSTING|ORIGIN|direct|cloud|proxy|app-verification|origin" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo "== package/framework hints =="
for f in package.json next.config.{js,mjs,ts} app.html app.py; do
  [ -f "$f" ] && { echo "-- $f"; sed -n '1,220p' "$f"; }
done
git ls-files | rg '(^|/)package\.json$|(^|/)next\.config\.(js|mjs|ts)$|(^|/)app\.html$|(^|/)app\.py$' || true

Repository: projectamazonph/amph-v2

Length of output: 50380


Do not trust the leftmost X-Forwarded-For value for rate limiting.

The auth actions use the first comma-separated value as signup:ip: / signin:ip: keys while an origin-reachable app can keep opening a fresh key each request, bypassing the per-IP 10-request rate limit. Use a verified client IP from the trusted hosting proxy boundary instead, or ensure origin requests cannot be reached directly.

Also applies to lines 136-149.

🤖 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/app/actions/auth.ts` around lines 36 - 47, Update the IP extraction used
by the signup and signin rate-limit flows around the x-forwarded-for handling to
use a verified client IP from the trusted hosting proxy boundary, rather than
the leftmost forwarded value. Ensure origin-direct requests cannot bypass the
per-IP keys, and apply the same change to both the signup and signin rate-limit
logic.

}

const existing = await db.user.findUnique({ where: { email: data.email } });
Expand Down Expand Up @@ -123,11 +133,20 @@ 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);
if (!rl.allowed) {
throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`);
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';

// Rate-limit BEFORE any DB or scrypt work — the async scrypt verify is
// protected from event-loop starvation by dual rate-limiting (IP & target).
const rlEmail = rateLimit(`signin:email:${data.email.toLowerCase()}`, 5, 60_000);
if (!rlEmail.allowed) {
throw new Error(`Too many attempts. Try again in ${rlEmail.retryAfterSeconds}s.`);
}

const rlIp = rateLimit(`signin:ip:${ip}`, 10, 60_000);
if (!rlIp.allowed) {
throw new Error(`Too many attempts from this IP. Try again in ${rlIp.retryAfterSeconds}s.`);
}

const user = await db.user.findUnique({ where: { email: data.email } });
Expand Down
98 changes: 98 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { rateLimit } from '../rate-limit';
Comment on lines +1 to +2

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 | 🟠 Major | ⚡ Quick win

Colocate both new test suites with their sources.

  • src/lib/__tests__/rate-limit.test.ts#L1-L2: move to src/lib/rate-limit.test.ts.
  • src/app/actions/__tests__/auth-actions.test.ts#L150-L204: move to src/app/actions/auth.test.ts.

As per coding guidelines, “Keep tests next to the code they test: foo.ts should have foo.test.ts.”

📍 Affects 2 files
  • src/lib/__tests__/rate-limit.test.ts#L1-L2 (this comment)
  • src/app/actions/__tests__/auth-actions.test.ts#L150-L204
🤖 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 - 2, Move the rate-limit
test suite from src/lib/__tests__/rate-limit.test.ts to
src/lib/rate-limit.test.ts, keeping its imports and tests unchanged. Move the
auth-actions test suite from src/app/actions/__tests__/auth-actions.test.ts to
src/app/actions/auth.test.ts, preserving the existing tests and updating
relative imports as needed.

Source: Coding guidelines


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

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

it('allows requests within limit and then blocks', () => {
const key = 'user_1';

// Default limit is 5 per 60,000 ms
for (let i = 0; i < 5; i++) {
const result = rateLimit(key);
expect(result.allowed).toBe(true);
expect(result.retryAfterSeconds).toBe(0);
}

// 6th request is blocked
const blockedResult = rateLimit(key);
expect(blockedResult.allowed).toBe(false);
expect(blockedResult.retryAfterSeconds).toBe(60); // 60 seconds remaining
});

it('respects custom limits and windows', () => {
const key = 'custom_user';
const limit = 3;
const windowMs = 10_000;

for (let i = 0; i < limit; i++) {
const result = rateLimit(key, limit, windowMs);
expect(result.allowed).toBe(true);
}

const blockedResult = rateLimit(key, limit, windowMs);
expect(blockedResult.allowed).toBe(false);
expect(blockedResult.retryAfterSeconds).toBe(10);
});

it('allows request again after window expires', () => {
const key = 'expiring_user';
const limit = 2;
const windowMs = 10_000;

expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
expect(rateLimit(key, limit, windowMs).allowed).toBe(false);

// Fast-forward time by 11 seconds
vi.advanceTimersByTime(11_000);

// Should be allowed again
const result = rateLimit(key, limit, windowMs);
expect(result.allowed).toBe(true);
});

it('does not record blocked attempts to extend lockout', () => {
const key = 'blocked_lockout_user';
const limit = 2;
const windowMs = 10_000;

expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);

// First blocked request at T=0
expect(rateLimit(key, limit, windowMs).allowed).toBe(false);

// Advance time by 6 seconds (oldest hit is still active)
vi.advanceTimersByTime(6000);

// Try again - still blocked
expect(rateLimit(key, limit, windowMs).allowed).toBe(false);

// Advance by another 5 seconds (T=11 seconds total)
vi.advanceTimersByTime(5000);

// Should be allowed now, because blocked requests didn't record new hits
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
});

it('performs opportunistic cleanup of old buckets when map grows large', () => {
// Fill buckets up to 10,001 unique keys
for (let i = 0; i < 10005; i++) {
rateLimit(`key_${i}`, 1, 10_000);
}

// Now advance time so all those buckets are expired
vi.advanceTimersByTime(15_000);

// Hit rateLimit again to trigger cleanup block
const result = rateLimit('new_key_after_cleanup', 1, 10_000);
expect(result.allowed).toBe(true);
});
});