-
Notifications
You must be signed in to change notification settings - Fork 0
🛡️ Sentinel: implement dual rate-limiting on authentication actions and resolve CI pnpm failure #132
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?
🛡️ Sentinel: implement dual rate-limiting on authentication actions and resolve CI pnpm failure #132
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 |
|---|---|---|
|
|
@@ -15,7 +15,7 @@ import { | |
| getSession, | ||
| } from '@/lib/auth'; | ||
| import { logger } from '@/lib/logger'; | ||
| import { rateLimit } from '@/lib/rate-limit'; | ||
| import { rateLimitDual } from '@/lib/rate-limit'; | ||
| import { | ||
| hashClaimToken, | ||
| PLACEHOLDER_PASSWORD_PREFIX, | ||
|
|
@@ -32,7 +32,15 @@ import { | |
| // --------------------------------------------------------------------------- | ||
|
|
||
| export const signUpAction = createSafeAction(signUpSchema, async (data) => { | ||
| const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000); | ||
| // Use dual rate-limiting combining client IP check and target-based checks (email). | ||
| // Rate-limit BEFORE any DB or scrypt work — the async scrypt verify can still | ||
| // be targeted to cause severe load. | ||
| const rl = await rateLimitDual('signup-ip', `signup:${data.email.toLowerCase()}`, { | ||
| ipLimit: 10, | ||
| ipWindowMs: 60_000, | ||
| targetLimit: 5, | ||
| targetWindowMs: 60_000, | ||
| }); | ||
|
Comment on lines
+38
to
+43
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 | ⚡ Quick win Make the IP limit block before one IP can exhaust the target limit. With Set the IP threshold below the target threshold, or change the policy so a single source cannot consume the full target quota. Add a regression test with the production thresholds. It must show that a second IP can still use the target after the first IP is blocked. Also applies to: 137-142 🤖 Prompt for AI Agents |
||
| if (!rl.allowed) { | ||
| throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); | ||
| } | ||
|
|
@@ -123,9 +131,15 @@ 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); | ||
| // Use dual rate-limiting combining client IP check and target-based checks (email). | ||
| // Rate-limit BEFORE any DB or scrypt work — the async scrypt verify can still | ||
| // be targeted to cause severe load. | ||
| const rl = await rateLimitDual('signin-ip', `signin:${data.email.toLowerCase()}`, { | ||
| ipLimit: 10, | ||
| ipWindowMs: 60_000, | ||
| targetLimit: 5, | ||
| targetWindowMs: 60_000, | ||
| }); | ||
| if (!rl.allowed) { | ||
| throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { rateLimit, rateLimitDual, resetRateLimits } from '../rate-limit'; | ||
|
|
||
| // Mock headers from next/headers | ||
| const mockHeaders = vi.fn(); | ||
| vi.mock('next/headers', () => ({ | ||
| headers: () => mockHeaders(), | ||
| })); | ||
|
|
||
| describe('rate limiting', () => { | ||
| beforeEach(() => { | ||
| resetRateLimits(); | ||
| mockHeaders.mockReset(); | ||
| }); | ||
|
|
||
| describe('rateLimit', () => { | ||
| it('allows hits up to limit and denies subsequent ones', () => { | ||
| const key = 'test-key'; | ||
| for (let i = 0; i < 5; i++) { | ||
| const result = rateLimit(key, 5, 60_000); | ||
| expect(result.allowed).toBe(true); | ||
| expect(result.retryAfterSeconds).toBe(0); | ||
| } | ||
|
|
||
| const deniedResult = rateLimit(key, 5, 60_000); | ||
| expect(deniedResult.allowed).toBe(false); | ||
| expect(deniedResult.retryAfterSeconds).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it('cleans up buckets map opportunistically', () => { | ||
| resetRateLimits(); | ||
| rateLimit('k1', 1, 1000); | ||
| resetRateLimits(); | ||
| const res = rateLimit('k1', 1, 1000); | ||
| expect(res.allowed).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('rateLimitDual', () => { | ||
| it('handles requests with no headers gracefully', async () => { | ||
| mockHeaders.mockRejectedValue(new Error('No header context')); | ||
|
|
||
| // IP defaults to "unknown" under fallback | ||
| const result = await rateLimitDual('action', 'Target@Example.Com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(result.allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('safely extracts client IP from x-forwarded-for first IP and is case-insensitive on target', async () => { | ||
| const mockGet = vi.fn().mockImplementation((name: string) => { | ||
| if (name === 'x-forwarded-for') return '1.2.3.4, 5.6.7.8'; | ||
| return null; | ||
| }); | ||
| mockHeaders.mockResolvedValue({ get: mockGet }); | ||
|
|
||
| // First hit | ||
| const res1 = await rateLimitDual('login', 'User@Example.Com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| // Same email, different casing - should count as the same target | ||
| const res2 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(res2.allowed).toBe(true); | ||
|
|
||
| // Third hit for same email/target - should be blocked | ||
| const res3 = await rateLimitDual('login', 'USER@EXAMPLE.COM', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(res3.allowed).toBe(false); | ||
|
Comment on lines
+51
to
+77
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 Test the target bucket without exhausting the IP bucket. The third request reaches Use an IP limit above three with distinct IPs if the target-key normalization belongs in 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| it('falls back to x-real-ip if x-forwarded-for is missing', async () => { | ||
| const mockGet = vi.fn().mockImplementation((name: string) => { | ||
| if (name === 'x-real-ip') return '9.9.9.9'; | ||
| return null; | ||
| }); | ||
| mockHeaders.mockResolvedValue({ get: mockGet }); | ||
|
|
||
| const res1 = await rateLimitDual('register', 'user1@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| // Second hit triggers IP limit | ||
| const res2 = await rateLimitDual('register', 'user2@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(res2.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('blocks by IP first, preventing target lockout bucket consumption', async () => { | ||
| const mockGet = vi.fn().mockImplementation((name: string) => { | ||
| if (name === 'x-real-ip') return '100.100.100.100'; | ||
| return null; | ||
| }); | ||
| mockHeaders.mockResolvedValue({ get: mockGet }); | ||
|
|
||
| // IP limit = 1, Target limit = 5 | ||
| // Hit 1: successful | ||
| const res1 = await rateLimitDual('test-action', 'victim@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| // Hit 2: blocked by IP limit | ||
| const res2 = await rateLimitDual('test-action', 'victim@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(res2.allowed).toBe(false); | ||
|
|
||
| // Hit 3: from a DIFFERENT IP, should still allow the victim to log in | ||
| // because target bucket was NOT polluted by the previous blocked request. | ||
| const mockGetNewIp = vi.fn().mockImplementation((name: string) => { | ||
| if (name === 'x-real-ip') return '200.200.200.200'; | ||
| return null; | ||
| }); | ||
| mockHeaders.mockResolvedValue({ get: mockGetNewIp }); | ||
|
|
||
| const res3 = await rateLimitDual('test-action', 'victim@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(res3.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 authentication comments.
Replace the em dashes with periods or commas. In
signUpAction, replace “scrypt verify” because this path hashes a new password and does not verify one.As per coding guidelines, “Do not use emojis in code or commit messages, and do not use em-dashes. Use periods, commas, or parentheses instead.”
Also applies to: 134-136
🤖 Prompt for AI Agents
Source: Coding guidelines