-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: [HIGH] Fix authentication credential stuffing via dual rate-limiting #89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ vi.mock('next/headers', () => ({ | |
| set: vi.fn(), | ||
| delete: vi.fn(), | ||
| }), | ||
| headers: () => Promise.resolve({ get: () => null }), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
π€ Prompt for AI AgentsSource: Coding guidelines |
||
| })); | ||
|
|
||
| vi.mock('next/navigation', () => ({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -200Repository: projectamazonph/amph-v2 Length of output: 11155 π Web query:
π‘ 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.
π Affects 1 file
π€ Prompt for AI Agents |
||
| } | ||
|
|
||
| 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.`); | ||
|
|
@@ -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.`); | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Maintainability & Code Quality | π‘ Minor | β‘ Quick win Co-locate this unit test with Move this file to As per coding guidelines, βKeep tests next to the code they test: foo.ts should have foo.test.ts.β π€ Prompt for AI AgentsSource: 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); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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
Source: Coding guidelines