-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: implement dual rate limiting for authentication actions #124
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 |
|---|---|---|
| @@ -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
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. π― Functional Correctness | π‘ Minor | β‘ Quick win Add a target-key normalization regression test. Call As per coding guidelines, βNew features must include tests.β π€ Prompt for AI AgentsSource: Coding guidelines |
||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| */ | ||
|
|
||
| import 'server-only'; | ||
| import { headers } from 'next/headers'; | ||
|
|
||
| const buckets = new Map<string, number[]>(); | ||
|
|
||
|
|
@@ -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
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 | π‘ 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\(' srcRepository: 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})
PYRepository: 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})
PYRepository: 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"])
PYRepository: projectamazonph/amph-v2 Length of output: 11719 π Web query:
π‘ 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 When both headers are absent, π Affects 2 files
π€ Prompt for AI Agents |
||
|
|
||
| // 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(); | ||
| } | ||
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.
π Security & Privacy | π Major | β‘ Quick win
Set the IP limit below the target limit.
With
ipLimit = 10andtargetLimit = 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