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-16 - Credential Stuffing and Distributed Brute-Force Risks in Single-Key Rate Limiting
**Vulnerability:** The application previously relied on a single-dimension rate limiter keyed solely on the user's lowercase email address for authentication endpoints (`signUpAction` and `signInAction`). While this protects any individual email account from being single-target brute-forced, it leaves the platform entirely vulnerable to distributed credential stuffing attacks (where an attacker tests thousands of unique email/password pairs, making only a single attempt per email) or distributed IP-based DoS attacks, both of which can exhaust database/CPU resources without triggering the email-keyed rate limiter.
**Learning:** Single-key rate limiting based only on username/email does not prevent horizontal brute-force (credential stuffing) or service exhaustion. Defensive authentication security requires dual layers of control to restrict abuse from individual clients while still allowing legitimate access patterns.
**Prevention:** Always implement dual rate-limiting in critical user authentication and mutation entry points by combining IP-based tracking (extracted safely from headers like `x-forwarded-for` and `x-real-ip`) and target-based tracking (like lowercase emails). This prevents single clients from sweeping across multiple user accounts.
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

Use plainer security language.

Define or replace terms such as β€œsingle-dimension,” β€œhorizontal brute-force,” and β€œservice exhaustion” so the guidance is understandable to the intended audience.

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

🧰 Tools
πŸͺ› LanguageTool

[uncategorized] ~8-~8: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...ributed Brute-Force Risks in Single-Key Rate Limiting Vulnerability: The application prev...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

πŸ€– 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, Rewrite the entry in
.jules/sentinel.md using plain, direct language for the Filipino VA audience:
replace or briefly define β€œsingle-dimension,” β€œhorizontal brute-force,” and
β€œservice exhaustion,” while preserving the guidance about combining IP-based and
email-based rate limiting for signUpAction and signInAction.

Source: Coding guidelines

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
1 change: 1 addition & 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,7 @@ vi.mock('next/headers', () => ({
set: vi.fn(),
delete: vi.fn(),
}),
headers: () => Promise.resolve({ get: () => null }),

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

Make the header mock configurable and test both IP paths.

get() always returns null, so these tests cannot exercise x-forwarded-for, x-real-ip fallback, or independent IP buckets. Use a configurable Vitest mock and add cases for the first forwarded IP, fallback, and blocking after ten requests.

πŸ€– 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` at line 32, Update the header
mock in the auth action tests to use a configurable Vitest mock for get(), then
add coverage for the first x-forwarded-for IP, x-real-ip fallback, and
independent IP buckets, including verification that the same IP is blocked after
ten requests.

Source: Coding guidelines

}));

vi.mock('next/navigation', () => ({
Expand Down
19 changes: 19 additions & 0 deletions src/app/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@/lib/auth';
import { logger } from '@/lib/logger';
import { rateLimit } from '@/lib/rate-limit';
import { headers } from 'next/headers';
import {
hashClaimToken,
PLACEHOLDER_PASSWORD_PREFIX,
Expand All @@ -32,6 +33,15 @@ import {
// ---------------------------------------------------------------------------

export const signUpAction = createSafeAction(signUpSchema, async (data) => {
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 ipRl = rateLimit(`signup:ip:${ip}`, 10, 60_000);
if (!ipRl.allowed) {
throw new Error(`Too many attempts from your IP. Try again in ${ipRl.retryAfterSeconds}s.`);
Comment on lines +36 to +42

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 "== locate auth.ts and related headers definitions =="
fd -a 'auth\.ts$|__tests__|setup\.ts$' . | sed 's#^\./##' | head -100

echo
echo "== src/app/actions/auth.ts relevant sections =="
if [ -f src/app/actions/auth.ts ]; then
  wc -l src/app/actions/auth.ts
  sed -n '1,190p' src/app/actions/auth.ts | cat -n
fi

echo
echo "== headers() implementations/imports =="
rg -n "function headers|const headers|headers:" src __tests__ tests 2>/dev/null || true

echo
echo "== setup and auth action tests relevant duplicates =="
for f in $(fd 'setup\.ts$|auth-actions\.test\.ts$|actions\.test\.ts$' src/__tests__ src/app/actions/__tests__ 2>/dev/null); do
  echo "--- $f"
  wc -l "$f"
  rg -n "headers|next/future|nextjs" "$f" -C 3 || true
done

echo
echo "== config/proxy/readme references for headers =="
rg -n "x-forwarded-for|x-real-ip|trust proxy|trustedHeaders|headers:" . -g '!node_modules' -g '!dist' -g '!build' | head -200

Repository: projectamazonph/amph-v2

Length of output: 11155


🌐 Web query:

Next.js headers() x-forwarded-for untrusted header security

πŸ’‘ Result:

Using the x-forwarded-for header to determine a client's IP address in Next.js is a common practice, but it presents a significant security risk if not handled correctly [1][2]. Because the x-forwarded-for header is an HTTP header, it can be easily spoofed by any client [1][3]. Security Risks If your application trusts the x-forwarded-for header without verification, malicious users can inject arbitrary IP addresses [2]. This leads to several critical vulnerabilities, particularly if the IP address is used for security-sensitive logic [3][2]: - Rate Limiting Bypass: Attackers can spoof their IP to bypass per-IP rate limits or IP-based blocks [3][2]. - Access Control Bypass: Features relying on IP allowlists (e.g., internal admin panels) can be circumvented [3][2]. - False Attribution: Audit logs, fraud detection, and analytics will record the spoofed IP instead of the actual client IP, potentially leading to misidentification [2]. Best Practices for Secure Usage The header is only reliable if you can verify that it was added by a trusted infrastructure component (e.g., your own load balancer, reverse proxy, or CDN) that you control [3][4]. 1. Trust Only Your Infrastructure: You must ensure that your application is only accessible through a trusted proxy [2][4]. If an attacker can bypass your proxy and connect directly to your server, they can send a request with a forged x-forwarded-for header, which your application might then accept as genuine [2]. 2. Identify the Trusted Chain: The x-forwarded-for header is a comma-separated list [5][4]. When a request passes through multiple proxies, the client's original IP is typically on the left, and subsequent proxies append their own IP to the right [3][4]. If you control the proxy chain, you must validate that the request originated from a known, trusted proxy IP before using any values provided by the client [3][4]. 3. Avoid Relying on Header-Only Logic: For high-security requirements, do not rely on header-based IP identification alone. If possible, use platform-provided secure connection metadata or infrastructure-level controls [5][4]. 4. Be Aware of Environment Variability: In development, local environments may not include these headers at all, whereas in production, managed hosting environments (like Vercel) or load balancers may automatically populate them [6][5]. Always verify the specific behavior of your hosting provider's load balancer [1][5]. In summary, treat x-forwarded-for as untrusted user input unless you have verified it is coming from a proxy infrastructure that you fully control [3][2][4]. Avoid using the leftmost value directly without ensuring the request has traversed your trusted proxy chain [3][4].

Citations:


Do not rate-limit using spoofable forwarding headers.

signUpAction and signInAction take x-forwarded-for as the first source and x-real-ip as the next source before falling back to unknown. Clients can forge either header and choose a new key per request, which keeps per-client auth attempts from being gated; header-less traffic shares the unknown bucket. Derive the IP only from a trusted request header supplied/verified by the hosting proxy or request metadata before these limits.

  • src/app/actions/auth.ts#L36-L42: use the trusted source for signup limits.
  • src/app/actions/auth.ts#L138-L144: use the same trusted source for signin limits.
πŸ“ Affects 1 file
  • src/app/actions/auth.ts#L36-L42 (this comment)
  • src/app/actions/auth.ts#L138-L144
πŸ€– 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 - 42, Update the IP derivation used
by signUpAction at src/app/actions/auth.ts lines 36-42 and signInAction at
src/app/actions/auth.ts lines 138-144 to use the trusted request IP source
supplied or verified by the hosting proxy or request metadata, rather than
x-forwarded-for or x-real-ip. Reuse the same trusted source and preserve the
existing signup:ip and signin:ip rate-limit behavior.

}

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.`);
Expand Down Expand Up @@ -125,6 +135,15 @@ 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 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 ipRl = rateLimit(`signin:ip:${ip}`, 10, 60_000);
if (!ipRl.allowed) {
throw new Error(`Too many attempts from your IP. Try again in ${ipRl.retryAfterSeconds}s.`);
}

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.`);
Expand Down
112 changes: 112 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } 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 | 🟑 Minor | ⚑ Quick win

Co-locate this unit test with rate-limit.ts.

Move this file to src/lib/rate-limit.test.ts so it sits next to src/lib/rate-limit.ts.

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/lib/__tests__/rate-limit.test.ts` around lines 1 - 2, Move the rate-limit
unit test from the __tests__ directory to src/lib/rate-limit.test.ts, keeping it
adjacent to rate-limit.ts. Preserve the existing test contents and imports,
updating only any relative paths required by the new location.

Source: Coding guidelines


describe('rate-limit.ts', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-16T12:00:00Z'));
});

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

it('allows hits within the limit', () => {
const key = 'user1';
const limit = 3;
const windowMs = 60_000;

// First 3 hits are allowed
for (let i = 0; i < limit; i++) {
const res = rateLimit(key, limit, windowMs);
expect(res.allowed).toBe(true);
expect(res.retryAfterSeconds).toBe(0);
vi.advanceTimersByTime(1000); // 1s apart
}
});

it('blocks hits exceeding the limit and returns correct retryAfterSeconds', () => {
const key = 'user2';
const limit = 3;
const windowMs = 60_000;

// Hit 1: t=0
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
vi.advanceTimersByTime(10_000); // Now t=10s

// Hit 2: t=10s
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
vi.advanceTimersByTime(10_000); // Now t=20s

// Hit 3: t=20s
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);

// Hit 4: t=20s (Exceeds limit!)
const blockedRes = rateLimit(key, limit, windowMs);
expect(blockedRes.allowed).toBe(false);
// Oldest hit was at t=0. Window is 60s.
// So the oldest hit will fall out at t=60s.
// Current time is t=20s.
// Remaining time: 60 - 20 = 40 seconds.
expect(blockedRes.retryAfterSeconds).toBe(40);
});

it('denied hits are not recorded and do not extend lockout window', () => {
const key = 'user3';
const limit = 2;
const windowMs = 60_000;

// Hit 1: t=0
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
vi.advanceTimersByTime(10_000); // t=10s

// Hit 2: t=10s
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);

// Hit 3: t=10s (Blocked)
expect(rateLimit(key, limit, windowMs).allowed).toBe(false);

// If denied hits were recorded, the sliding window would have hits at t=0, t=10s, t=10s.
// Since it's not recorded, advancing by 51s (t=61s) means hit 1 (t=0) fell out.
// Now only 1 hit remains (t=10s). Hit 4 should be allowed.
vi.advanceTimersByTime(51_000); // t=61s
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
});

it('sliding window lets hits fall out and allows new requests', () => {
const key = 'user4';
const limit = 2;
const windowMs = 60_000;

// Hit 1: t=0
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);
vi.advanceTimersByTime(40_000); // t=40s

// Hit 2: t=40s
expect(rateLimit(key, limit, windowMs).allowed).toBe(true);

// Hit 3: t=40s (Blocked)
expect(rateLimit(key, limit, windowMs).allowed).toBe(false);

// Advance 21 seconds to t=61s (first hit at t=0 has expired)
vi.advanceTimersByTime(21_000); // t=61s
const res = rateLimit(key, limit, windowMs);
expect(res.allowed).toBe(true);
});

it('performs opportunistic cleanup of the internal map when size exceeds threshold', () => {
const windowMs = 60_000;

// Fill the limiter with 10,001 entries to trigger size cleanup
for (let i = 0; i < 10_005; i++) {
rateLimit(`key-${i}`, 5, windowMs);
}

// Now advance time so all those keys are expired
vi.advanceTimersByTime(windowMs + 1000);

// Trigger another rateLimit call to trigger cleanup
const res = rateLimit('new-key', 5, windowMs);
expect(res.allowed).toBe(true);
});
});