-
Notifications
You must be signed in to change notification settings - Fork 0
🛡️ Sentinel: implement dual rate-limiting on authentication server actions #122
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
c2d0283
d26e297
1fc72ad
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,216 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| import { headers } from 'next/headers'; | ||
| import { rateLimit, rateLimitDual, resetRateLimits } from '../rate-limit'; | ||
|
|
||
| vi.mock('next/headers', () => ({ | ||
| headers: vi.fn(), | ||
| })); | ||
|
|
||
| describe('Rate Limiter', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| resetRateLimits(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| describe('rateLimit', () => { | ||
| it('allows hits within limits and blocks when exceeded', () => { | ||
| // Limit 3 hits in 1 minute (60,000 ms) | ||
| expect(rateLimit('user1', 3, 60_000).allowed).toBe(true); | ||
| expect(rateLimit('user1', 3, 60_000).allowed).toBe(true); | ||
| expect(rateLimit('user1', 3, 60_000).allowed).toBe(true); | ||
|
|
||
| // 4th hit should be blocked | ||
| const blocked = rateLimit('user1', 3, 60_000); | ||
| expect(blocked.allowed).toBe(false); | ||
| expect(blocked.retryAfterSeconds).toBe(60); // 1 minute (60s) | ||
| }); | ||
|
|
||
| it('sliding window lets hits expire', () => { | ||
| expect(rateLimit('user1', 2, 10_000).allowed).toBe(true); | ||
|
|
||
| // Advance time by 5s | ||
| vi.advanceTimersByTime(5000); | ||
| expect(rateLimit('user1', 2, 10_000).allowed).toBe(true); | ||
|
|
||
| // Third hit within 10s should block | ||
| expect(rateLimit('user1', 2, 10_000).allowed).toBe(false); | ||
|
|
||
| // Advance time by 6s (total 11s), first hit has expired, so we should be allowed again | ||
| vi.advanceTimersByTime(6000); | ||
| expect(rateLimit('user1', 2, 10_000).allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('cleans up obsolete buckets', () => { | ||
| // We need to insert > 10,000 buckets to trigger cleanup. | ||
| for (let i = 0; i < 10001; i++) { | ||
| rateLimit(`user_${i}`, 1, 10_000); | ||
| } | ||
| // Buckets are now full. Let's advance timers so they are obsolete | ||
| vi.advanceTimersByTime(11000); | ||
| // Trigger another rate limit to run cleanup | ||
| rateLimit('trigger', 1, 10_000); | ||
|
|
||
| // Since all other 10001 buckets were obsolete, they should be cleaned up. | ||
| // Let's verify that hitting an existing one starts a fresh limit instead of old one | ||
| const res = rateLimit('user_0', 1, 10_000); | ||
| expect(res.allowed).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('rateLimitDual', () => { | ||
| it('applies IP rate limit before target-based check', async () => { | ||
| // Mock headers with IP 1.2.3.4 | ||
| (headers as any).mockResolvedValue({ | ||
|
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 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Confirm all unsafe casts are removed from this test file.
rg -n -C 2 '\bheaders\s+as\s+any\b|\bas\s+any\b' src/lib/__tests__/rate-limit.test.tsRepository: projectamazonph/amph-v2 Length of output: 1382 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Relevant test file sections:"
sed -n '1,220p' src/lib/__tests__/rate-limit.test.ts
echo
echo "Find exported mock helpers and headers type usage:"
rg -n "headers\(|mockResolvedValue|mockRejectedValue|vi\.mocked|as any|type Headers|interface Headers" src/lib __tests__ . --glob '!node_modules' --glob '!dist' --glob '!build' | head -200Repository: projectamazonph/amph-v2 Length of output: 29530 Remove
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| get: (name: string) => { | ||
| if (name === 'x-forwarded-for') return '1.2.3.4, 5.6.7.8'; | ||
| return null; | ||
| }, | ||
| }); | ||
|
|
||
| // Target limit is 2, IP limit is 3. | ||
| // 1st request | ||
| const r1 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 3, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(r1.allowed).toBe(true); | ||
|
|
||
| // 2nd request | ||
| const r2 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 3, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(r2.allowed).toBe(true); | ||
|
|
||
| // 3rd request should hit target limit (since targetLimit is 2) | ||
| const r3 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 3, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(r3.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('blocks by IP limit before target limit, and does not record target-based hit on IP failure', async () => { | ||
| // Mock headers with IP 1.2.3.4 | ||
| let currentIp = '1.2.3.4'; | ||
| (headers as any).mockResolvedValue({ | ||
| get: (name: string) => { | ||
| if (name === 'x-forwarded-for') return currentIp; | ||
| return null; | ||
| }, | ||
| }); | ||
|
|
||
| // Target limit is 5, IP limit is 2 | ||
| const r1 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 2, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r1.allowed).toBe(true); | ||
|
|
||
| const r2 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 2, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r2.allowed).toBe(true); | ||
|
|
||
| // 3rd request hits IP limit first | ||
| const r3 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 2, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r3.allowed).toBe(false); | ||
|
|
||
| // Crucial: since IP blocked the 3rd request, target-based bucket should NOT have been polluted | ||
| // i.e. target-based hits should still be 2. Let's change IP to 2.2.2.2 and try same target | ||
| currentIp = '2.2.2.2'; | ||
|
|
||
| // Target hit count is still 2. We can hit it 3 more times with new IPs each time to avoid IP limit. | ||
| const r4 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 2, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r4.allowed).toBe(true); // target hit 3 | ||
|
|
||
| currentIp = '3.3.3.3'; | ||
| const r5 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 2, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r5.allowed).toBe(true); // target hit 4 | ||
|
|
||
| currentIp = '4.4.4.4'; | ||
| const r6 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 2, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r6.allowed).toBe(true); // target hit 5 | ||
|
|
||
| currentIp = '5.5.5.5'; | ||
| const r7 = await rateLimitDual('ip-test', 'target-test', { | ||
| ipLimit: 2, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r7.allowed).toBe(false); // target limit exceeded | ||
| }); | ||
|
|
||
| it('falls back to 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 r1 = await rateLimitDual('ip-real', 'target-real', { ipLimit: 1 }); | ||
| expect(r1.allowed).toBe(true); | ||
|
|
||
| // 2nd hit should block on IP | ||
| const r2 = await rateLimitDual('ip-real', 'target-real2', { ipLimit: 1 }); | ||
| expect(r2.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('skips IP check and uses target check if no IP headers exist', async () => { | ||
| (headers as any).mockResolvedValue({ | ||
| get: () => null, | ||
| }); | ||
|
|
||
| const r1 = await rateLimitDual('ip-none', 'target-only', { ipLimit: 1, targetLimit: 2 }); | ||
| expect(r1.allowed).toBe(true); | ||
|
|
||
| const r2 = await rateLimitDual('ip-none', 'target-only', { ipLimit: 1, targetLimit: 2 }); | ||
| expect(r2.allowed).toBe(true); | ||
|
|
||
| const r3 = await rateLimitDual('ip-none', 'target-only', { ipLimit: 1, targetLimit: 2 }); | ||
| expect(r3.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('falls back gracefully to target-only rate limiting if headers() throws', async () => { | ||
| (headers as any).mockRejectedValue(new Error('Outside of request context')); | ||
|
|
||
| const r1 = await rateLimitDual('ip-error', 'target-fallback', { ipLimit: 1, targetLimit: 2 }); | ||
| expect(r1.allowed).toBe(true); | ||
|
|
||
| const r2 = await rateLimitDual('ip-error', 'target-fallback', { ipLimit: 1, targetLimit: 2 }); | ||
| expect(r2.allowed).toBe(true); | ||
|
|
||
| const r3 = await rateLimitDual('ip-error', 'target-fallback', { ipLimit: 1, targetLimit: 2 }); | ||
| expect(r3.allowed).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('resetRateLimits', () => { | ||
| it('clears rate limiting maps', () => { | ||
| expect(rateLimit('u', 1).allowed).toBe(true); | ||
| expect(rateLimit('u', 1).allowed).toBe(false); | ||
|
|
||
| resetRateLimits(); | ||
|
|
||
| expect(rateLimit('u', 1).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,57 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR | |
|
|
||
| return { allowed: true, retryAfterSeconds: 0 }; | ||
| } | ||
|
|
||
| /** | ||
| * Dual rate limiter: combines client IP check and target-based checks (like email). | ||
| * Always executes the client IP check and rate limiting before checking the target-based | ||
| * identifier to prevent malicious IP-blocked actors from polluting target-based buckets | ||
| * and causing an Account Lockout Denial of Service (DoS) for legitimate users. | ||
| */ | ||
| export async function rateLimitDual( | ||
| ipKeyPrefix: string, | ||
| targetKey: string, | ||
| options: { | ||
| ipLimit?: number; | ||
| ipWindowMs?: number; | ||
| targetLimit?: number; | ||
| targetWindowMs?: number; | ||
| } = {} | ||
| ): Promise<RateLimitResult> { | ||
| const { | ||
| ipLimit = 10, | ||
| ipWindowMs = 60_000, | ||
| targetLimit = 5, | ||
| targetWindowMs = 60_000, | ||
| } = options; | ||
|
|
||
| let heads; | ||
| try { | ||
| heads = await headers(); | ||
| } catch { | ||
| // Fallback gracefully if called outside of request context (e.g. testing) | ||
| } | ||
|
|
||
| if (heads) { | ||
| const xff = heads.get('x-forwarded-for'); | ||
| // Safe access for split under noUncheckedIndexedAccess rule | ||
| const ip = xff ? xff.split(',')[0]?.trim() : (heads.get('x-real-ip') ?? null); | ||
|
|
||
| if (ip) { | ||
| const ipKey = `${ipKeyPrefix}:${ip}`; | ||
| const ipResult = rateLimit(ipKey, ipLimit, ipWindowMs); | ||
| if (!ipResult.allowed) { | ||
| return ipResult; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return rateLimit(targetKey, targetLimit, targetWindowMs); | ||
|
Comment on lines
+89
to
+96
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
# Identify current deployment and shared-cache capabilities before selecting a limiter backend.
fd -HI -t f '^(package\.json|Dockerfile.*|docker-compose.*|vercel\.json|.*\.ya?ml)$' . \
-x sh -c 'echo "== $1 =="; rg -n -i -C 2 "redis|upstash|rate.limit|replica|serverless|scale" "$1" || true' sh {}Repository: projectamazonph/amph-v2 Length of output: 1049 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg '(^|/)(package\.json|packages\.json|pnpm-workspace\.yaml|.*github/workflows/deploy.*\.ya?ml|app\.json|next\.config\.(js|mjs|ts)|vercel\.json|package\.json|Dockerfile.*|docker-compose.*|.*\.ya?ml)$' || true
echo
echo "== rate-limit outline =="
ast-grep outline src/lib/rate-limit.ts --view expanded || true
echo
echo "== rate-limit relevant content =="
sed -n '1,140p' src/lib/rate-limit.ts
echo
echo "== usages of rateLimitDual/resetRateLimits =="
rg -n "rateLimitDual|resetRateLimits|from ['\"].*rate-limit" .Repository: projectamazonph/amph-v2 Length of output: 7400 Store authentication rate-limit state in shared storage.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Resets the in-memory rate-limiting buckets (primarily for testing purposes). | ||
| */ | ||
| 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 | 🟡 Minor | ⚡ Quick win
Add action-level regression tests for the dual limiter.
The utility tests do not prove that both authentication actions enforce limits before database and password work. Add focused tests for denied requests and confirm that each action does not call its database or password-processing dependency after the limiter denies the request.
src/app/actions/auth.ts#L35-L41: add a sign-up action test for IP or email limit denial before user lookup or creation.src/app/actions/auth.ts#L132-L140: add a sign-in action test for IP or email limit denial before user lookup and password verification.📍 Affects 1 file
src/app/actions/auth.ts#L35-L41(this comment)src/app/actions/auth.ts#L132-L140🤖 Prompt for AI Agents
Source: Coding guidelines