-
Notifications
You must be signed in to change notification settings - Fork 0
🛡️ Sentinel: Dual Rate Limiting on Auth Actions #130
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
2854023
dcc5479
905fe4f
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,83 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { rateLimit, rateLimitDual, resetRateLimits } from '../rate-limit'; | ||
|
|
||
| vi.mock('next/headers', () => ({ | ||
| headers: vi.fn(() => Promise.resolve({ get: () => null })), | ||
| })); | ||
|
|
||
| import { headers } from 'next/headers'; | ||
|
|
||
| describe('rate-limit.ts', () => { | ||
| beforeEach(() => { | ||
| resetRateLimits(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('rateLimit', () => { | ||
| it('allows requests within limit and then blocks', () => { | ||
| const key = 'test-key'; | ||
| expect(rateLimit(key, 2).allowed).toBe(true); | ||
| expect(rateLimit(key, 2).allowed).toBe(true); | ||
| expect(rateLimit(key, 2).allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('clears limits with resetRateLimits', () => { | ||
| const key = 'test-key'; | ||
| expect(rateLimit(key, 1).allowed).toBe(true); | ||
| expect(rateLimit(key, 1).allowed).toBe(false); | ||
|
|
||
| resetRateLimits(); | ||
|
|
||
| expect(rateLimit(key, 1).allowed).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('rateLimitDual', () => { | ||
| it('applies IP and target limits sequentially', async () => { | ||
| (headers as any).mockResolvedValue({ | ||
| get: (name: string) => { | ||
| if (name === 'x-forwarded-for') return '1.2.3.4'; | ||
| return null; | ||
| }, | ||
| }); | ||
|
|
||
| const key = 'target-key'; | ||
|
|
||
| const res1 = await rateLimitDual(key, 2, 60_000, 3, 60_000); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| const res2 = await rateLimitDual(key, 2, 60_000, 3, 60_000); | ||
| expect(res2.allowed).toBe(true); | ||
|
|
||
| const res3 = await rateLimitDual(key, 2, 60_000, 3, 60_000); | ||
| expect(res3.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('blocks IP before target-based bucket is polluted', async () => { | ||
| (headers as any).mockResolvedValue({ | ||
| get: (name: string) => { | ||
| if (name === 'x-forwarded-for') return '5.5.5.5'; | ||
| return null; | ||
| }, | ||
| }); | ||
|
|
||
| const res1 = await rateLimitDual('target-1', 5, 60_000, 1, 60_000); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| const res2 = await rateLimitDual('target-2', 5, 60_000, 1, 60_000); | ||
| expect(res2.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('extracts IP from x-real-ip if x-forwarded-for is missing', async () => { | ||
| (headers as any).mockResolvedValue({ | ||
| get: (name: string) => { | ||
| if (name === 'x-real-ip') return '9.9.9.9'; | ||
| return null; | ||
| }, | ||
| }); | ||
|
|
||
| const res = await rateLimitDual('t', 5, 60_000, 1, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
| }); | ||
| }); | ||
| }); | ||
| 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,40 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR | |
|
|
||
| return { allowed: true, retryAfterSeconds: 0 }; | ||
| } | ||
|
|
||
| /** | ||
| * Perform asynchronous rate limiting using client IP and target-based identifiers. | ||
| * Always run client IP rate check BEFORE target check to prevent account-lockout DoS. | ||
| */ | ||
| export async function rateLimitDual( | ||
| targetKey: string, | ||
| targetLimit = 5, | ||
| targetWindowMs = 60_000, | ||
| ipLimit = 10, | ||
| ipWindowMs = 60_000, | ||
| ): Promise<RateLimitResult> { | ||
| let ip: string | null = null; | ||
| try { | ||
| const headersList = await headers(); | ||
| const xff = headersList.get('x-forwarded-for'); | ||
| ip = xff ? (xff.split(',')[0]?.trim() ?? null) : (headersList.get('x-real-ip') ?? null); | ||
| } catch { | ||
| // Graceful fallback if headers() cannot be called or fails | ||
| } | ||
|
|
||
| // IP rate limiting check first | ||
| const ipResult = rateLimit(`ip:${ip ?? 'unknown'}`, ipLimit, ipWindowMs); | ||
|
Comment on lines
+63
to
+73
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
# Find repository configuration or documentation that defines forwarded-header handling.
rg -n -i -C 3 'x-forwarded-for|x-real-ip|forwarded header|trusted proxy|client ip' \
. -g '!node_modules/**' -g '!.git/**'
# Confirm the deployed ingress strips client-provided address headers and writes
# the exact trusted header consumed by src/lib/rate-limit.ts.Repository: projectamazonph/amph-v2 Length of output: 4506 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- tracked deployment/config files ---'
git ls-files | rg -i '(^|/)(dockerfile|docker-compose|nginx|caddy|traefik|vercel|netlify|cloudflare|render|railway|fly|kubernetes|k8s|helm|terraform|pulumi|github/workflows|middleware|next\.config|README|docs)'
printf '%s\n' '--- rate-limit implementation and call sites ---'
sed -n '1,130p' src/lib/rate-limit.ts
rg -n -C 4 'rateLimitDual|rateLimit\(' src app 2>/dev/null || true
printf '%s\n' '--- relevant package/framework configuration ---'
rg -n -i -C 3 'next|proxy|trust|forwarded|deployment|hosting|ingress|x-forwarded-for|x-real-ip' \
package.json package-lock.json pnpm-lock.yaml yarn.lock next.config.* middleware.* src app docs README.md .github 2>/dev/null || true
printf '%s\n' '--- rate-limit tests ---'
sed -n '1,130p' src/lib/__tests__/rate-limit.test.tsRepository: projectamazonph/amph-v2 Length of output: 50380 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- deployment and security documentation references ---'
rg -n -i -C 5 'Vercel|x-forwarded-for|x-real-ip|client IP|proxy|rate.limit|rate limit|unknown' \
README.md docs/LAUNCH-DEPLOY.md docs/SECURITY.md docs/security docs/runbooks .github/workflows \
--glob '*.md' --glob '*.yml' --glob '*.yaml' | head -n 500
printf '%s\n' '--- middleware and configuration ---'
sed -n '1,220p' src/middleware.ts
sed -n '1,140p' next.config.ts
printf '%s\n' '--- exact authentication call sites ---'
sed -n '1,155p' src/app/actions/auth.tsRepository: projectamazonph/amph-v2 Length of output: 47653 🌐 Web query:
💡 Result: Vercel overwrites the standard X-Forwarded-For header when a deployment is accessed behind a reverse proxy [1][2]. This behavior is a security measure designed to prevent IP spoofing, as the platform cannot inherently trust client-supplied headers from arbitrary external sources [1]. If you are using a proxy in front of Vercel, the X-Forwarded-For header will typically contain the IP address of your proxy rather than the original client IP [3]. To reliably identify the original client IP when using a reverse proxy, you should follow these guidelines: 1. Use Vercel's Verified Proxy feature: Vercel supports "Verified Proxy" to securely process traffic from known reverse proxies [4][5]. This prevents your proxy's IP addresses from being flagged by DDoS mitigation systems and allows for proper identification of request sources [4][5]. 2. Identify the correct provider-specific header: Rather than relying on X-Forwarded-For, you should use the specific header provided by your CDN or load balancer that carries the true client IP [6][5]. Examples include: - Cloudflare: CF-Connecting-IP [6][5] - Fastly: Fastly-Client-IP [7][5] - Google Cloud Load Balancing: X-GCP-Connecting-IP [6][5] - AWS CloudFront: CloudFront-Viewer-Address [6][5] - Akamai: True-Client-IP [6][5] - Azure Front Door: X-Azure-ClientIP [4][5] - F5: X-F5-True-Client-IP [6][7] For providers that are not automatically recognized, you may need to contact Vercel support to configure the Trusted Proxy feature for your account, which requires static egress IP addresses and specific header configurations [6][4][5]. If you do not have a proxy in front of Vercel and are experiencing unexpected IP values, note that internal Vercel headers like x-vercel-forwarded-for may be available for internal platform use, but your primary concern when routing through your own infrastructure is configuring the trusted proxy relationship to ensure the correct originating IP is passed through or maintained [1][2]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from collections import defaultdict
from time import time
# Model the exact rateLimitDual fallback and default IP limit from
# src/lib/rate-limit.ts.
buckets = defaultdict(list)
now = time()
for request_number in range(1, 12):
key = "ip:unknown"
hits = [t for t in buckets[key] if t > now - 60]
allowed = len(hits) < 10
if allowed:
hits.append(now)
buckets[key] = hits
print(f"request {request_number}: {'allowed' if allowed else 'blocked'}")
PY
printf '%s\n' '--- official Vercel request-header guidance ---'
curl -L --fail --silent --show-error \
https://vercel.com/docs/headers/request-headers |
tr '\n' ' ' |
sed 's/<[^>]*>/ /g' |
grep -o -i -E '.{0,180}x-forwarded-for.{0,300}' |
head -n 5 || trueRepository: projectamazonph/amph-v2 Length of output: 2847 Avoid the shared Direct Vercel deployments overwrite 🤖 Prompt for AI Agents |
||
| if (!ipResult.allowed) { | ||
| return ipResult; | ||
| } | ||
|
|
||
| // Target-based rate limiting check second | ||
| return rateLimit(targetKey, targetLimit, targetWindowMs); | ||
| } | ||
|
|
||
| /** | ||
| * Clear in-memory rate-limiting maps to prevent inter-test interference. | ||
| */ | ||
| 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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Strengthen the dual rate-limit regression test and type the headers mock.
The IP-first case only asserts that the second request is rejected; it would also pass if the target bucket were consumed before the IP rejection. Use a target limit of one, issue the rejected request, switch to a fresh IP, and assert that the same target is still allowed. Replace the
(headers as any)casts with a typed Vitest mock/helper. Keep the test undersrc/lib/__tests__, which matches the repository's existing test layout.📍 Affects 1 file
src/lib/__tests__/rate-limit.test.ts#L56-L68(this comment)src/lib/__tests__/rate-limit.test.ts#L37-L42src/lib/__tests__/rate-limit.test.ts#L1-L2🤖 Prompt for AI Agents
Source: Coding guidelines