-
Notifications
You must be signed in to change notification settings - Fork 0
🛡️ Sentinel: Dual rate-limiting for auth endpoints #91
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 |
|---|---|---|
|
|
@@ -23,12 +23,19 @@ vi.mock('@/lib/db', () => ({ | |
| }, | ||
| })); | ||
|
|
||
| const { mockHeaders } = vi.hoisted(() => ({ | ||
| mockHeaders: { | ||
| get: vi.fn().mockReturnValue(null), | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('next/headers', () => ({ | ||
| cookies: () => ({ | ||
| get: () => undefined, | ||
| set: vi.fn(), | ||
| delete: vi.fn(), | ||
| }), | ||
| headers: () => Promise.resolve(mockHeaders), | ||
| })); | ||
|
|
||
| vi.mock('next/navigation', () => ({ | ||
|
|
@@ -139,4 +146,60 @@ describe('auth actions', () => { | |
| expect(result.success).toBe(true); | ||
| expect((result as any).data.ok).toBe(true); | ||
| }); | ||
|
|
||
| describe('dual rate-limiting', () => { | ||
| beforeEach(() => { | ||
| mockHeaders.get.mockImplementation((header: string) => { | ||
| if (header === 'x-forwarded-for') return '203.0.113.195'; | ||
| return null; | ||
| }); | ||
| }); | ||
|
|
||
| it('rate-limits by email on multiple sign-in attempts for same email', async () => { | ||
| (db.user.findUnique as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(null); | ||
|
|
||
| // Trigger rate limit with 5 allowed requests | ||
| for (let i = 0; i < 5; i++) { | ||
| const res = await signInAction({ email: 'target@example.com', password: 'x' }); | ||
| expect(res.success).toBe(false); | ||
| if (!res.success) { | ||
| expect(res.error).toBe('Email or password is incorrect.'); | ||
| } | ||
| } | ||
|
|
||
| // 6th request triggers email rate limit | ||
| const blockedRes = await signInAction({ email: 'target@example.com', password: 'x' }); | ||
| expect(blockedRes.success).toBe(false); | ||
| if (!blockedRes.success) { | ||
| expect(blockedRes.error).toMatch(/Too many attempts\. Try again in \d+s\./); | ||
| } | ||
| }); | ||
|
|
||
| it('rate-limits by IP on multiple sign-in attempts from same IP but different emails', async () => { | ||
| (db.user.findUnique as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(null); | ||
|
|
||
| // Use a distinct IP | ||
| mockHeaders.get.mockImplementation((header: string) => { | ||
| if (header === 'x-forwarded-for') return '198.51.100.42'; | ||
| return null; | ||
| }); | ||
|
|
||
| // 10 attempts on different emails from same IP are allowed to check credentials (but fail credential check) | ||
| for (let i = 0; i < 10; i++) { | ||
| const email = `user-${i}@example.com`; | ||
| const res = await signInAction({ email, password: 'x' }); | ||
| expect(res.success).toBe(false); | ||
| if (!res.success) { | ||
| expect(res.error).toBe('Email or password is incorrect.'); | ||
| } | ||
| } | ||
|
|
||
| // 11th request triggers IP rate limit | ||
| const blockedRes = await signInAction({ email: 'user-11@example.com', password: 'x' }); | ||
| expect(blockedRes.success).toBe(false); | ||
| if (!blockedRes.success) { | ||
| expect(blockedRes.error).toBe('Too many attempts from this IP. Try again in 60s.'); | ||
| } | ||
| }); | ||
| }); | ||
|
Comment on lines
+150
to
+204
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 | 🟠 Major | ⚡ Quick win Keep the auth action test beside Move this suite 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 |
||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| 'use server'; | ||
|
|
||
| import { redirect } from 'next/navigation'; | ||
| import { headers } from 'next/headers'; | ||
| import { db } from '@/lib/db'; | ||
| import { | ||
| hashPassword, | ||
|
|
@@ -32,9 +33,18 @@ import { | |
| // --------------------------------------------------------------------------- | ||
|
|
||
| export const signUpAction = createSafeAction(signUpSchema, async (data) => { | ||
| 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.`); | ||
| 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 rlEmail = rateLimit(`signup:email:${data.email.toLowerCase()}`, 5, 60_000); | ||
| if (!rlEmail.allowed) { | ||
| throw new Error(`Too many attempts. Try again in ${rlEmail.retryAfterSeconds}s.`); | ||
| } | ||
|
|
||
| const rlIp = rateLimit(`signup:ip:${ip}`, 10, 60_000); | ||
| if (!rlIp.allowed) { | ||
| throw new Error(`Too many attempts from this IP. Try again in ${rlIp.retryAfterSeconds}s.`); | ||
|
Comment on lines
+36
to
+47
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 "== files =="
git ls-files | rg '(^|/)auth\.ts$|rate(limit|limiter)?|src/app/actions' || true
echo "== auth outline =="
ast-grep outline src/app/actions/auth.ts || true
echo "== auth relevant lines =="
sed -n '1,190p' src/app/actions/auth.ts | cat -n
echo "== rate limiter references =="
rg -n "function rateLimit|const rateLimit|rateLimit|signup:email:|signup:ip:" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || trueRepository: projectamazonph/amph-v2 Length of output: 11369 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== rate limit implementation and tests =="
sed -n '1,180p' src/lib/rate-limit.ts | cat -n
sed -n '1,140p' src/lib/__tests__/rate-limit.test.ts | cat -n
echo "== auth tests around signup/signin =="
rg -n "signUpAction|signInAction|signup|signin|Too many attempts from this IP|Too many attempts" src/app/actions/__tests__ src/app/actions/__tests__/auth-actions.test.ts --context 3 || true
echo "== deployment/reverse proxy/origin direct access references =="
rg -n "x-forwarded-for|Forwarded|Client-IP|x-real-ip|VERCEL|HOSTING|ORIGIN|direct|cloud|proxy|app-verification|origin" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true
echo "== package/framework hints =="
for f in package.json next.config.{js,mjs,ts} app.html app.py; do
[ -f "$f" ] && { echo "-- $f"; sed -n '1,220p' "$f"; }
done
git ls-files | rg '(^|/)package\.json$|(^|/)next\.config\.(js|mjs|ts)$|(^|/)app\.html$|(^|/)app\.py$' || trueRepository: projectamazonph/amph-v2 Length of output: 50380 Do not trust the leftmost The auth actions use the first comma-separated value as Also applies to lines 136-149. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| const existing = await db.user.findUnique({ where: { email: data.email } }); | ||
|
|
@@ -123,11 +133,20 @@ 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 rl = rateLimit(`signin:${data.email.toLowerCase()}`, 5, 60_000); | ||
| if (!rl.allowed) { | ||
| throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); | ||
| 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'; | ||
|
|
||
| // Rate-limit BEFORE any DB or scrypt work — the async scrypt verify is | ||
| // protected from event-loop starvation by dual rate-limiting (IP & target). | ||
| const rlEmail = rateLimit(`signin:email:${data.email.toLowerCase()}`, 5, 60_000); | ||
| if (!rlEmail.allowed) { | ||
| throw new Error(`Too many attempts. Try again in ${rlEmail.retryAfterSeconds}s.`); | ||
| } | ||
|
|
||
| const rlIp = rateLimit(`signin:ip:${ip}`, 10, 60_000); | ||
| if (!rlIp.allowed) { | ||
| throw new Error(`Too many attempts from this IP. Try again in ${rlIp.retryAfterSeconds}s.`); | ||
| } | ||
|
|
||
| const user = await db.user.findUnique({ where: { email: data.email } }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import { describe, it, expect, beforeEach, afterEach, vi } 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 | 🟠 Major | ⚡ Quick win Colocate both new test suites with their sources.
As per coding guidelines, “Keep tests next to the code they test: foo.ts should have foo.test.ts.” 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| describe('rate-limit.ts', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it('allows requests within limit and then blocks', () => { | ||
| const key = 'user_1'; | ||
|
|
||
| // Default limit is 5 per 60,000 ms | ||
| for (let i = 0; i < 5; i++) { | ||
| const result = rateLimit(key); | ||
| expect(result.allowed).toBe(true); | ||
| expect(result.retryAfterSeconds).toBe(0); | ||
| } | ||
|
|
||
| // 6th request is blocked | ||
| const blockedResult = rateLimit(key); | ||
| expect(blockedResult.allowed).toBe(false); | ||
| expect(blockedResult.retryAfterSeconds).toBe(60); // 60 seconds remaining | ||
| }); | ||
|
|
||
| it('respects custom limits and windows', () => { | ||
| const key = 'custom_user'; | ||
| const limit = 3; | ||
| const windowMs = 10_000; | ||
|
|
||
| for (let i = 0; i < limit; i++) { | ||
| const result = rateLimit(key, limit, windowMs); | ||
| expect(result.allowed).toBe(true); | ||
| } | ||
|
|
||
| const blockedResult = rateLimit(key, limit, windowMs); | ||
| expect(blockedResult.allowed).toBe(false); | ||
| expect(blockedResult.retryAfterSeconds).toBe(10); | ||
| }); | ||
|
|
||
| it('allows request again after window expires', () => { | ||
| const key = 'expiring_user'; | ||
| const limit = 2; | ||
| const windowMs = 10_000; | ||
|
|
||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(true); | ||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(true); | ||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(false); | ||
|
|
||
| // Fast-forward time by 11 seconds | ||
| vi.advanceTimersByTime(11_000); | ||
|
|
||
| // Should be allowed again | ||
| const result = rateLimit(key, limit, windowMs); | ||
| expect(result.allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('does not record blocked attempts to extend lockout', () => { | ||
| const key = 'blocked_lockout_user'; | ||
| const limit = 2; | ||
| const windowMs = 10_000; | ||
|
|
||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(true); | ||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(true); | ||
|
|
||
| // First blocked request at T=0 | ||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(false); | ||
|
|
||
| // Advance time by 6 seconds (oldest hit is still active) | ||
| vi.advanceTimersByTime(6000); | ||
|
|
||
| // Try again - still blocked | ||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(false); | ||
|
|
||
| // Advance by another 5 seconds (T=11 seconds total) | ||
| vi.advanceTimersByTime(5000); | ||
|
|
||
| // Should be allowed now, because blocked requests didn't record new hits | ||
| expect(rateLimit(key, limit, windowMs).allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('performs opportunistic cleanup of old buckets when map grows large', () => { | ||
| // Fill buckets up to 10,001 unique keys | ||
| for (let i = 0; i < 10005; i++) { | ||
| rateLimit(`key_${i}`, 1, 10_000); | ||
| } | ||
|
|
||
| // Now advance time so all those buckets are expired | ||
| vi.advanceTimersByTime(15_000); | ||
|
|
||
| // Hit rateLimit again to trigger cleanup block | ||
| const result = rateLimit('new_key_after_cleanup', 1, 10_000); | ||
| expect(result.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
Correct the old limiter description and define the attack terms.
Line 9 assigns the sign-in key to both actions, although sign-up used its own
signup:key. Also define “credential stuffing” and avoid unexplained phrases such as “distributed dictionary attack.”Proposed rewrite
As per coding guidelines, “Use direct, plain-spoken language for the Filipino VA audience, define jargon, and avoid generic AI-slop phrases.”
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines