-
Notifications
You must be signed in to change notification settings - Fork 0
🛡️ Sentinel: dual rate-limiting to prevent IP/Account Lockout DoS #113
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
155eeff
36fe0f6
1629992
55d5d7d
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 |
|---|---|---|
| @@ -1,3 +1,6 @@ | ||
| packages: | ||
| - '.' | ||
|
|
||
| onlyBuiltDependencies: | ||
| - "@prisma/client" | ||
| - "@prisma/engines" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
|
|
||
| const mockHeadersGet = vi.fn<(key: string) => string | null>(() => null); | ||
|
|
||
| vi.mock('next/headers', () => ({ | ||
| headers: () => Promise.resolve({ | ||
| get: (key: string) => mockHeadersGet(key), | ||
| }), | ||
| })); | ||
|
|
||
| import { rateLimit, rateLimitDual } from '../rate-limit'; | ||
|
|
||
| describe('rate-limit.ts', () => { | ||
| beforeEach(() => { | ||
| mockHeadersGet.mockReset(); | ||
| vi.useFakeTimers(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| describe('rateLimit', () => { | ||
| it('allows hits under the limit and blocks exceeding hits within the window', () => { | ||
| const key = 'test:rateLimit'; | ||
|
|
||
| // 5 allowed hits | ||
| for (let i = 0; i < 5; i++) { | ||
| const res = rateLimit(key, 5, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
| expect(res.retryAfterSeconds).toBe(0); | ||
| } | ||
|
|
||
| // 6th hit blocked | ||
| const blockedRes = rateLimit(key, 5, 60_000); | ||
| expect(blockedRes.allowed).toBe(false); | ||
| expect(blockedRes.retryAfterSeconds).toBe(60); | ||
|
|
||
| // Advance time by 30 seconds, still blocked | ||
| vi.advanceTimersByTime(30_000); | ||
| const blockedRes2 = rateLimit(key, 5, 60_000); | ||
| expect(blockedRes2.allowed).toBe(false); | ||
| expect(blockedRes2.retryAfterSeconds).toBe(30); | ||
|
|
||
| // Advance time by another 31 seconds, oldest hit falls out of window | ||
| vi.advanceTimersByTime(31_000); | ||
| const allowedRes = rateLimit(key, 5, 60_000); | ||
| expect(allowedRes.allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('does not record blocked hits so retryAfterSeconds does not get extended indefinitely', () => { | ||
| const key = 'test:blocked'; | ||
|
|
||
| // Burn limit | ||
| for (let i = 0; i < 5; i++) { | ||
| rateLimit(key, 5, 60_000); | ||
| } | ||
|
|
||
| // Advance 50 seconds | ||
| vi.advanceTimersByTime(50_000); | ||
|
|
||
| // Blocked hit | ||
| const res1 = rateLimit(key, 5, 60_000); | ||
| expect(res1.allowed).toBe(false); | ||
|
|
||
| // Advance 11 seconds (total 61s from start). The original hits expired, and blocked hits shouldn't count. | ||
| vi.advanceTimersByTime(11_000); | ||
| const res2 = rateLimit(key, 5, 60_000); | ||
| expect(res2.allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('performs opportunistic cleanup when buckets map size exceeds 10,000', () => { | ||
| // Fill buckets past 10,000 with expired entries | ||
| for (let i = 0; i < 10005; i++) { | ||
| rateLimit(`cleanup:${i}`, 5, 60_000); | ||
| } | ||
| // Advance time past windowMs | ||
| vi.advanceTimersByTime(65_000); | ||
| // Trigger cleanup | ||
| rateLimit('trigger-cleanup', 5, 60_000); | ||
|
|
||
| // The cleanup should have run. Check that adding to cleanup:0 is fresh | ||
| const res = rateLimit('cleanup:0', 5, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('rateLimitDual', () => { | ||
| it('uses x-forwarded-for header if present (trimmed first item)', async () => { | ||
| mockHeadersGet.mockImplementation((key) => { | ||
| if (key === 'x-forwarded-for') return ' 1.2.3.4, 5.6.7.8 '; | ||
| return null; | ||
| }); | ||
|
|
||
| const res = await rateLimitDual('target:1', 1, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
|
|
||
| // Check that IP bucket for 1.2.3.4 is populated and blocks | ||
| mockHeadersGet.mockImplementation((key) => { | ||
| if (key === 'x-forwarded-for') return '1.2.3.4'; | ||
| return null; | ||
| }); | ||
| const blockedRes = await rateLimitDual('target:other', 1, 60_000); | ||
| expect(blockedRes.allowed).toBe(false); // IP 1.2.3.4 blocked | ||
| }); | ||
|
|
||
| it('falls back to x-real-ip if x-forwarded-for is absent', async () => { | ||
| mockHeadersGet.mockImplementation((key) => { | ||
| if (key === 'x-real-ip') return '8.8.8.8'; | ||
| return null; | ||
| }); | ||
|
|
||
| const res = await rateLimitDual('target:2', 1, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
|
|
||
| // Blocked on real IP | ||
| const blockedRes = await rateLimitDual('target:other', 1, 60_000); | ||
| expect(blockedRes.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('falls back to unknown if both headers are absent', async () => { | ||
| mockHeadersGet.mockReturnValue(null); | ||
|
|
||
| const res = await rateLimitDual('target:3', 1, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
|
|
||
| // Blocked on unknown IP | ||
| const blockedRes = await rateLimitDual('target:other', 1, 60_000); | ||
| expect(blockedRes.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('blocks IP first and does not pollute target bucket', async () => { | ||
| // Set IP | ||
| mockHeadersGet.mockImplementation((key) => { | ||
| if (key === 'x-forwarded-for') return '9.9.9.9'; | ||
| return null; | ||
| }); | ||
|
|
||
| const target = 'target:pollute-test'; | ||
|
|
||
| // Burn the IP rate limit with other targets | ||
| await rateLimitDual('dummy:1', 1, 60_000); | ||
|
|
||
| // Now IP is blocked. A hit with the target should fail at the IP layer first. | ||
| const blockedIpRes = await rateLimitDual(target, 1, 60_000); | ||
| expect(blockedIpRes.allowed).toBe(false); | ||
|
|
||
| // Now change the IP. The target limit shouldn't have been hit/polluted at all because the IP block prevented the target check. | ||
| mockHeadersGet.mockImplementation((key) => { | ||
| if (key === 'x-forwarded-for') return '10.10.10.10'; | ||
| return null; | ||
| }); | ||
|
|
||
| const allowedRes = await rateLimitDual(target, 1, 60_000); | ||
| expect(allowedRes.allowed).toBe(true); // target still has its allowance because target check was bypassed when IP was blocked | ||
| }); | ||
| }); | ||
| }); | ||
| 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,33 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR | |
|
|
||
| return { allowed: true, retryAfterSeconds: 0 }; | ||
| } | ||
|
|
||
| /** | ||
| * Asynchronously dual rate-limit client IP first, then the target-based key. | ||
| * Prevents account lockout attacks by rejecting blocked IPs before updating target buckets. | ||
| */ | ||
| export async function rateLimitDual( | ||
| targetKey: string, | ||
| limit = 5, | ||
| windowMs = 60_000 | ||
| ): Promise<RateLimitResult> { | ||
| 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'; | ||
|
Comment on lines
+61
to
+63
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. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect repository deployment configuration and documentation for forwarding-header handling.
fd -HI -t f . | rg '(next\.config|vercel\.json|Dockerfile|docker-compose|nginx|caddy|traefik|kubernetes|helm|README|docs)' \
| while IFS= read -r file; do
rg -n -i -C 3 'x-forwarded-for|x-real-ip|trusted proxy|proxy_set_header|real_ip_header' "$file" || true
doneRepository: projectamazonph/amph-v2 Length of output: 161 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== candidate files =="
fd -HI -t f 'rate-limit|rate-limit\.test|vercel\.json|next\.config|Dockerfile|docker-compose|.*nginx.*|.*caddy.*|.*traefik.*|.*kubernetes.*|.*helm.*|README|docs/voice-guide' . \
| sed -n '1,120p'
echo "== src/lib/rate-limit.ts outline =="
ast-grep outline src/lib/rate-limit.ts 2>/dev/null || true
echo "== src/lib/rate-limit.ts relevant lines =="
cat -n src/lib/rate-limit.ts | sed -n '1,150p'
echo "== src/lib/__tests__/rate-limit.test.ts relevant lines =="
cat -n src/lib/__tests__/rate-limit.test.ts | sed -n '1,220p'
echo "== search for use of limitRequest/rate limiting helpers =="
rg -n "limitRequest|rateLimit|rate-limit|unknown|x-forwarded-for|x-real-ip" src package.json -SRepository: projectamazonph/amph-v2 Length of output: 31800 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== files =="
fd -HI -t f 'rate-limit|rate-limit\.test|vercel\.json|next\.config|Dockerfile|docker-compose|.*nginx.*|.*caddy.*|.*traefik.*|.*kubernetes.*|.*helm.*|README|docs/voice-guide' . \
| sed -n '1,120p'
echo "== rate-limit source excerpt =="
cat -n src/lib/rate-limit.ts | sed -n '1,150p'
echo "== rate-limit tests excerpt =="
cat -n src/lib/__tests__/rate-limit.test.ts | sed -n '1,220p'
echo "== usages/excerpts =="
rg -n "limitRequest|rateLimit|rate-limit|unknown|x-forwarded-for|x-real-ip" src package.json -SRepository: projectamazonph/amph-v2 Length of output: 31357 Do not rate-limit missing-identity requests in a shared When both Verify the deployment ingress replaces client-supplied 🤖 Prompt for AI Agents |
||
|
|
||
| // Always check and record IP limit first. | ||
| const ipRl = rateLimit(`ip:${ip}`, limit, windowMs); | ||
| if (!ipRl.allowed) { | ||
| return ipRl; | ||
| } | ||
|
|
||
| // Only check and record target limit if IP check passed. | ||
| return rateLimit(targetKey, limit, windowMs); | ||
| } | ||
|
|
||
| /** | ||
| * Resets all rate limit buckets (primarily for testing isolation). | ||
| */ | ||
| 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.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Reset rate-limit state before each test.
bucketspersists across test cases. ImportresetRateLimitsand call it inbeforeEachso each test starts with isolated limiter state.🤖 Prompt for AI Agents