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
@@ -1,5 +1,10 @@
# Sentinel Journal β€” Critical Security Learnings

## 2026-07-17 - Dual Rate Limiting Prevents Account Lockout DoS
**Vulnerability:** Simple rate limiting on sensitive authentication actions (sign-in/sign-up) based purely on target-specific identifiers (e.g., username/email) enables malicious actors to trigger rate limits for legitimate users. An attacker could flood authentication requests for a specific user email, resulting in an Account Lockout Denial of Service (DoS) for the victim.
**Learning:** Rate limiting must protect both system resources and user experience. Enforcing target-based rate limits before client IP rate limits allows malicious IPs to pollute and exhaust the target's limit bucket.
**Prevention:** Implement a dual rate-limiting utility where client IP-based limits are verified and recorded *before* target-based limits. This ensures that malicious IP addresses are blocked first, preventing them from exhausting the target's quota and preserving account availability for the legitimate owner.

## 2026-07-16 - Synchronous Password Hashing Blocks Next.js Event Loop (DoS Risk)
**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.
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+sha512.ZsGsTH1HYtbX3eRMfz5ac1ke0KCAbnUdTtMtTwBPJbIoWpBrH9ip4+Yh3ztOKFi/iOUODPYmvtvpd/5DSlyvhQ=="
}
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
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, 10, 5, 60_000);

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 | ⚑ Quick win

Set the IP limit below the target limit.

With ipLimit = 10 and targetLimit = 5, one IP can submit five requests for a victim email and exhaust its target bucket before the IP bucket blocks it. IP-first ordering does not prevent this lockout.

  • src/app/actions/auth.ts#L35-L35: set the signup IP limit below the target limit, or change the target-bucket policy.
  • src/app/actions/auth.ts#L128-L128: apply the same safe limit relationship to signin.
  • .jules/sentinel.md#L3-L6: state that IP-first ordering reduces lockout risk only when the IP quota blocks the source before it exhausts the target quota.
πŸ“ Affects 2 files
  • src/app/actions/auth.ts#L35-L35 (this comment)
  • src/app/actions/auth.ts#L128-L128
  • .jules/sentinel.md#L3-L6
πŸ€– 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` at line 35, Set the signup rate-limit configuration
at src/app/actions/auth.ts lines 35-35 so the IP quota is lower than the target
quota, or adjust the target-bucket policy accordingly; apply the same safe
relationship at src/app/actions/auth.ts lines 128-128 for signin. Update
.jules/sentinel.md lines 3-6 to state that IP-first ordering reduces lockout
risk only when the IP quota blocks the source before the target quota is
exhausted.

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

// Mock 'next/headers' as requested
const mockHeadersGet = vi.fn();
vi.mock('next/headers', () => ({
headers: () => Promise.resolve({
get: mockHeadersGet,
}),
}));

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

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

describe('rateLimit', () => {
it('allows hits within the limit and blocks exceeding hits', () => {
// Limit of 3 hits in 60s
expect(rateLimit('key1', 3, 60_000).allowed).toBe(true);
expect(rateLimit('key1', 3, 60_000).allowed).toBe(true);
expect(rateLimit('key1', 3, 60_000).allowed).toBe(true);

const blocked = rateLimit('key1', 3, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterSeconds).toBe(60);
});

it('advances the window correctly over time', () => {
expect(rateLimit('key1', 2, 10_000).allowed).toBe(true);
vi.advanceTimersByTime(6000);
expect(rateLimit('key1', 2, 10_000).allowed).toBe(true);

// Exceeded
expect(rateLimit('key1', 2, 10_000).allowed).toBe(false);

// Advance so first hit falls out of the window
vi.advanceTimersByTime(5000); // Total 11s elapsed since 1st hit
expect(rateLimit('key1', 2, 10_000).allowed).toBe(true);
});

it('cleans up buckets opportunistically when map is too large', () => {
// Trigger opportunistic cleanup (buckets.size > 10_000)
for (let i = 0; i < 10005; i++) {
rateLimit(`key-${i}`, 1, 10);
}
// Advance time so everything is cutoff
vi.advanceTimersByTime(20);
// This call triggers cleanup
rateLimit('newkey', 1, 10);
// The old keys should have been cleaned up and removed.
});
});

describe('rateLimitDual', () => {
it('uses x-forwarded-for header for client IP rate limiting', async () => {
mockHeadersGet.mockImplementation((header: string) => {
if (header === 'x-forwarded-for') return '1.2.3.4, 5.6.7.8';
return null;
});

// Under IP limit of 2, target limit of 1
// First attempt: should succeed for both IP and target
const res1 = await rateLimitDual('login', 'user@example.com', 2, 1, 60_000);
expect(res1.allowed).toBe(true);

// Second attempt with same email: target limit exceeded (limit is 1)
const res2 = await rateLimitDual('login', 'user@example.com', 2, 1, 60_000);
expect(res2.allowed).toBe(false);
});

it('IP limit check runs BEFORE target check to prevent account lockout', async () => {
mockHeadersGet.mockImplementation((header: string) => {
if (header === 'x-forwarded-for') return '9.9.9.9';
return null;
});

// IP limit = 1, target limit = 5
// First attempt: allowed
const res1 = await rateLimitDual('login', 'legit@example.com', 1, 5, 60_000);
expect(res1.allowed).toBe(true);

// Second attempt: IP blocked (since IP limit is 1)
const res2 = await rateLimitDual('login', 'legit@example.com', 1, 5, 60_000);
expect(res2.allowed).toBe(false);

// Verify target-based key did not get polluted by checking another IP
mockHeadersGet.mockImplementation((header: string) => {
if (header === 'x-forwarded-for') return '8.8.8.8'; // safe IP
return null;
});

// Legit user on safe IP can still log in because their target bucket wasn't polluted by blocked IP
const res3 = await rateLimitDual('login', 'legit@example.com', 1, 5, 60_000);
expect(res3.allowed).toBe(true);
});

it('falls back to x-real-ip when x-forwarded-for is missing', async () => {
mockHeadersGet.mockImplementation((header: string) => {
if (header === 'x-real-ip') return '127.0.0.1';
return null;
});

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

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

const res = await rateLimitDual('login', 'user@example.com', 1, 1, 60_000);
expect(res.allowed).toBe(true);
});
});
Comment on lines +61 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

Add a target-key normalization regression test.

Call rateLimitDual with the same target in mixed case. Assert that the second call reaches the same target bucket.

As per coding guidelines, β€œNew features must include tests.”

πŸ€– 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 61 - 120, The
rateLimitDual tests lack coverage proving target keys are normalized
consistently. Add a test in the rateLimitDual suite that calls rateLimitDual
with the same target using different casing and verifies the second call is
rejected or otherwise shares the first call’s target bucket under a target limit
of one.

Source: Coding guidelines

});
41 changes: 41 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,43 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR

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

/**
* Perform dual rate limiting to prevent both brute-force attacks and target-based lockout DoS.
*
* Always runs the client IP check BEFORE checking target-based keys (such as email) so that
* blocked IPs cannot fill/exhaust a legitimate user's target rate-limiting bucket.
*/
export async function rateLimitDual(
action: string,
targetKey: string,
ipLimit = 10,
targetLimit = 5,
windowMs = 60_000,
): Promise<RateLimitResult> {
const reqHeaders = await headers();
const xff = reqHeaders.get('x-forwarded-for');
const xri = reqHeaders.get('x-real-ip');

// Safely extract client IP from x-forwarded-for (handling noUncheckedIndexedAccess)
const clientIp = (xff ? xff.split(',')[0]?.trim() : null) || xri || 'unknown';
Comment on lines +66 to +70

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 | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -i -C 3 \
  'x-forwarded-for|x-real-ip|trust proxy|remoteAddress|forwarded' \
  -g '!node_modules/**' -g '!**/.next/**' .

Repository: projectamazonph/amph-v2

Length of output: 4376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- rate-limit structure ---'
ast-grep outline src/lib/rate-limit.ts --view expanded
echo '--- rate-limit implementation ---'
cat -n src/lib/rate-limit.ts | sed -n '1,150p'

echo '--- rate-limit test structure and relevant tests ---'
ast-grep outline src/lib/__tests__/rate-limit.test.ts --view expanded
cat -n src/lib/__tests__/rate-limit.test.ts | sed -n '1,145p'

echo '--- rateLimitDual call sites ---'
rg -n -C 5 'rateLimitDual|rateLimit\(' src

Repository: projectamazonph/amph-v2

Length of output: 22721


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- all proxy and client-address references ---'
rg -n -i -C 3 \
  'x-forwarded-for|x-real-ip|forwarded|remoteAddress|trusted.?proxy|proxy.?trust|vercel|cloudflare|load.?balancer|ingress' \
  --glob '!node_modules/**' --glob '!**/.next/**' .

echo '--- deployment and framework configuration files ---'
git ls-files | rg '(^|/)(vercel\.json|next\.config\..*|middleware\..*|package\.json|Dockerfile.*|docker-compose.*|README.*|.*security.*|.*audit.*)$' | sort

echo '--- deterministic model of the headerless path ---'
python3 - <<'PY'
from collections import defaultdict

def simulate(requests: int, ip_limit: int, target_limit: int):
    buckets = defaultdict(int)
    results = []
    for _ in range(requests):
        ip_key = "ip:login:unknown"
        if buckets[ip_key] >= ip_limit:
            results.append(False)
            continue
        buckets[ip_key] += 1
        target_key = "target:login:user@example.com"
        if buckets[target_key] >= target_limit:
            results.append(False)
            continue
        buckets[target_key] += 1
        results.append(True)
    return results, dict(buckets)

results, buckets = simulate(11, 10, 100)
print({"allowed_count": sum(results), "request_10": results[9], "request_11": results[10], "buckets": buckets})
PY

Repository: projectamazonph/amph-v2

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- all proxy and client-address references ---'
rg -n -i -C 3 \
  'x-forwarded-for|x-real-ip|forwarded|remoteAddress|trusted.?proxy|proxy.?trust|vercel|cloudflare|load.?balancer|ingress' \
  --glob '!node_modules/**' --glob '!**/.next/**' .

echo '--- deployment and framework configuration files ---'
git ls-files | rg '(^|/)(vercel\.json|next\.config\..*|middleware\..*|package\.json|Dockerfile.*|docker-compose.*|README.*|.*security.*|.*audit.*)$' | sort

echo '--- deterministic model of the headerless path ---'
python3 - <<'PY'
from collections import defaultdict

def simulate(requests: int, ip_limit: int, target_limit: int):
    buckets = defaultdict(int)
    results = []
    for _ in range(requests):
        ip_key = "ip:login:unknown"
        if buckets[ip_key] >= ip_limit:
            results.append(False)
            continue
        buckets[ip_key] += 1
        target_key = "target:login:user@example.com"
        if buckets[target_key] >= target_limit:
            results.append(False)
            continue
        target_key = "target:login:user@example.com"
        if buckets[target_key] >= target_limit:
            results.append(False)
            continue
        target_key = "target:login:user@example.com"
        buckets[target_key] += 1
        results.append(True)
    return results, dict(buckets)

results, buckets = simulate(11, 10, 100)
print({"allowed_count": sum(results), "request_10": results[9], "request_11": results[10], "buckets": buckets})
PY

Repository: projectamazonph/amph-v2

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- exact deployment/framework files ---'
for f in $(git ls-files | rg '(^|/)(vercel\.json|next\.config\..*|middleware\..*|Dockerfile.*|docker-compose.*)$'); do
  echo "### $f"
  cat -n "$f"
done

echo '--- focused proxy references outside generated/lock/status files ---'
rg -n -i \
  'x-forwarded-for|x-real-ip|forwarded|remoteAddress|trusted.?proxy|proxy.?trust|client.?ip' \
  src next.config.ts middleware.ts middleware.js vercel.json 2>/dev/null || true

echo '--- deterministic limiter model ---'
python3 - <<'PY'
from collections import defaultdict

def rate_limit(buckets, key, limit):
    if buckets[key] >= limit:
        return False
    buckets[key] += 1
    return True

buckets = defaultdict(int)
results = []
for _ in range(11):
    if not rate_limit(buckets, "ip:signin:unknown", 10):
        results.append(False)
        continue
    results.append(rate_limit(buckets, "target:signin:user@example.com", 100))

print("allowed requests:", sum(results))
print("request 10 allowed:", results[9])
print("request 11 allowed:", results[10])
print("unknown IP hits:", buckets["ip:signin:unknown"])
PY

Repository: projectamazonph/amph-v2

Length of output: 11719


🌐 Web query:

Vercel documentation x-forwarded-for x-real-ip client supplied spoofing trusted IP headers

πŸ’‘ Result:

Vercel takes specific measures to prevent IP spoofing by managing request headers like X-Forwarded-For and X-Real-IP [1]. Key points regarding Vercel's handling of these headers include: 1. Automatic Overwriting: To prevent IP spoofing, Vercel overwrites the standard X-Forwarded-For header for requests processed by its platform [1]. Consequently, you cannot rely on this header to pass external, client-supplied IP addresses directly to a Vercel deployment, as they will be replaced by the public IP address of the client as seen by Vercel's network [1]. 2. Equivalent Headers: The X-Real-IP header is treated as identical to X-Forwarded-For and is similarly managed by Vercel [1]. Additionally, Vercel provides x-vercel-forwarded-for, which contains the same information but is specifically intended for use in environments where you might need to distinguish the original IP from headers that could be overwritten by upstream proxies [1]. 3. Reverse Proxies and Verified Proxy: Vercel generally discourages placing a reverse proxy in front of a Vercel project because it obscures the end-user IP and reduces the effectiveness of Vercel's firewall [2]. However, if you must use a reverse proxy, Vercel offers a feature called Verified Proxy [3][4]. This system allows Vercel to trust specific headers (such as CF-Connecting-IP, True-Client-IP, or X-GCP-Connecting-IP) passed from known, verified providers to correctly identify the real client IP [3][4]. 4. Security Implications: Because client-supplied X-Forwarded-For headers are untrusted, any application logic relying on these headers for security, rate limiting, or access control is inherently vulnerable to spoofing unless handled through the Verified Proxy configuration for known, static infrastructure [2][3]. For applications requiring access to the original client IP, you should use the headers provided by Vercel's network or ensure your infrastructure is correctly onboarded to the Verified Proxy program if using an external load balancer or CDN [3][4].

Citations:


Avoid the shared unknown IP bucket.

When both headers are absent, rateLimitDual blocks all headerless clients after ten requests in the same instance. Use a platform-provided or validated client IP, and skip the IP bucket when no trusted address exists. Add a repeated headerless-request regression test.

πŸ“ Affects 2 files
  • src/lib/rate-limit.ts#L66-L70 (this comment)
  • src/lib/__tests__/rate-limit.test.ts#L114-L119
πŸ€– 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 66 - 70, The rateLimitDual client-IP
selection currently assigns headerless requests to a shared unknown bucket.
Update rateLimitDual to use a platform-provided or validated client IP and skip
IP-based limiting when no trusted address is available; preserve other
rate-limit behavior. In src/lib/__tests__/rate-limit.test.ts lines 114-119, add
a regression test covering repeated headerless requests and verifying they are
not blocked by the shared IP bucket.


// 1. Check IP-based rate limiting first
const ipKey = `ip:${action}:${clientIp}`;
const ipResult = rateLimit(ipKey, ipLimit, windowMs);
if (!ipResult.allowed) {
return ipResult;
}

// 2. Check target-based rate limiting second (using lowercase keys for case insensitivity)
const targetKeyLower = targetKey.toLowerCase();
const targetKeyFormatted = `target:${action}:${targetKeyLower}`;
return rateLimit(targetKeyFormatted, targetLimit, windowMs);
}

/**
* Resets the in-memory rate-limiting maps to prevent test pollution.
*/
export function resetRateLimits(): void {
buckets.clear();
}