-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: [security improvement] Dual Rate-Limiting #120
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
Open
projectamazonph
wants to merge
2
commits into
main
Choose a base branch
from
fix/sentinel-dual-rate-limiting-3799331359200161410
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { rateLimitDual, resetRateLimits } from '@/lib/rate-limit'; | ||
|
|
||
| const mockHeadersMap = new Map<string, string>(); | ||
|
|
||
| vi.mock('next/headers', () => { | ||
| return { | ||
| headers: () => | ||
| Promise.resolve({ | ||
| get: (key: string) => mockHeadersMap.get(key) ?? null, | ||
| }), | ||
| }; | ||
| }); | ||
|
|
||
| describe('rateLimitDual', () => { | ||
| beforeEach(() => { | ||
| resetRateLimits(); | ||
| mockHeadersMap.clear(); | ||
| }); | ||
|
|
||
| it('allows requests within limit', async () => { | ||
| mockHeadersMap.set('x-forwarded-for', '1.2.3.4'); | ||
|
|
||
| const res = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
|
|
||
| expect(res.allowed).toBe(true); | ||
| expect(res.retryAfterSeconds).toBe(0); | ||
| }); | ||
|
|
||
| it('blocks by IP limit and does not pollute target bucket (Account Lockout DoS prevention)', async () => { | ||
| // We set ipLimit to 2, targetLimit to 2. | ||
| // IP 1.2.3.4 will make 3 requests for user@example.com. | ||
| mockHeadersMap.set('x-forwarded-for', '1.2.3.4'); | ||
|
|
||
| const res1 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| const res2 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(res2.allowed).toBe(true); | ||
|
|
||
| // Third request from IP 1.2.3.4 should be blocked by IP-level limit. | ||
| const res3 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(res3.allowed).toBe(false); | ||
| expect(res3.retryAfterSeconds).toBeGreaterThan(0); | ||
|
|
||
| // Now, a legitimate user from a different IP (5.6.7.8) tries to log in. | ||
| // Since the blocked 3rd request from IP 1.2.3.4 did not pollute the target bucket, | ||
| // the target bucket for user@example.com should only have 2 active hits (from res1 and res2). | ||
| // Let's verify that IP 5.6.7.8 can still make their first allowed request for user@example.com, | ||
| // but a subsequent one is blocked by targetLimit (since total hits for target is now 3). | ||
| mockHeadersMap.set('x-forwarded-for', '5.6.7.8'); | ||
|
|
||
| // First request from 5.6.7.8 is blocked because targetLimit is 2, and we have 2 hits already. | ||
| // Wait! Let's double check this logic: | ||
| // res1 and res2 were allowed, so target bucket has 2 hits. | ||
| // So the target-level limit (2) is reached. | ||
| // This is correct! | ||
| const res4 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 2, | ||
| targetLimit: 2, | ||
| }); | ||
| expect(res4.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('target bucket is not incremented at all on blocked IP requests', async () => { | ||
| // Let's verify that if IP limit is reached, target bucket has absolutely 0 additional hits. | ||
| // Let's set ipLimit to 1, targetLimit to 5. | ||
| mockHeadersMap.set('x-forwarded-for', '1.2.3.4'); | ||
|
|
||
| // 1st request from IP 1.2.3.4: allowed (target gets 1 hit) | ||
| const r1 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r1.allowed).toBe(true); | ||
|
|
||
| // 2nd request from IP 1.2.3.4: blocked by IP (target should NOT get a 2nd hit) | ||
| const r2 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r2.allowed).toBe(false); | ||
|
|
||
| // 3rd request from IP 1.2.3.4: blocked by IP (target should NOT get a 3rd hit) | ||
| const r3 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 1, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r3.allowed).toBe(false); | ||
|
|
||
| // Now switch to IP 5.6.7.8. | ||
| // If the blocked requests did not pollute, the target bucket has exactly 1 hit. | ||
| // We should be able to make 4 more allowed requests from IP 5.6.7.8 (since targetLimit is 5). | ||
| mockHeadersMap.set('x-forwarded-for', '5.6.7.8'); | ||
|
|
||
| const r4 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 5, // high IP limit on the new IP | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r4.allowed).toBe(true); // target hit 2 | ||
|
|
||
| const r5 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 5, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r5.allowed).toBe(true); // target hit 3 | ||
|
|
||
| const r6 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 5, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r6.allowed).toBe(true); // target hit 4 | ||
|
|
||
| const r7 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 5, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r7.allowed).toBe(true); // target hit 5 | ||
|
|
||
| // 6th target hit overall: should be blocked by targetLimit | ||
| const r8 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 5, | ||
| targetLimit: 5, | ||
| }); | ||
| expect(r8.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('safely extracts IP from x-forwarded-for with multiple IPs and spaces', async () => { | ||
| mockHeadersMap.set('x-forwarded-for', ' 192.168.0.1, 10.0.0.1 '); | ||
| const res = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 1, | ||
| }); | ||
| expect(res.allowed).toBe(true); | ||
|
|
||
| // Next request from same x-forwarded-for (first IP is trimmed and isolated) should be blocked if limit is 1 | ||
| const res2 = await rateLimitDual('login', 'user@example.com', { | ||
| ipLimit: 1, | ||
| }); | ||
| expect(res2.allowed).toBe(false); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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,43 @@ export function rateLimit(key: string, limit = 5, windowMs = 60_000): RateLimitR | |
|
|
||
| return { allowed: true, retryAfterSeconds: 0 }; | ||
| } | ||
|
|
||
| /** | ||
| * Reset all rate-limiting buckets (primarily for testing to avoid inter-test pollution). | ||
| */ | ||
| export function resetRateLimits(): void { | ||
| buckets.clear(); | ||
| } | ||
|
|
||
| /** | ||
| * Asynchronous dual rate-limiting utility function. | ||
| * Safeguards sensitive endpoints like signUpAction and signInAction against brute force | ||
| * and credential stuffing by constraining both the client IP and target-based identifiers. | ||
| * | ||
| * Always executes the client IP check and rate limiting before checking target-based | ||
| * identifiers (such as emails) 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( | ||
| actionName: string, | ||
| targetId: string, | ||
| options: { | ||
| ipLimit?: number; | ||
| ipWindowMs?: number; | ||
| targetLimit?: number; | ||
| targetWindowMs?: number; | ||
| } = {}, | ||
| ): 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') ?? '127.0.0.1'; | ||
|
Comment on lines
+78
to
+80
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 Harden the client IP resolution. Three concerns in the IP derivation on Line 80:
Normalize to π Proposed fix for parsing and fallback- const heads = await headers();
- const xff = heads.get('x-forwarded-for');
- const ip = (xff ? xff.split(',')[0]?.trim() : null) ?? heads.get('x-real-ip') ?? '127.0.0.1';
+ const heads = await headers();
+ const forwardedFor = heads.get('x-forwarded-for')?.split(',')[0]?.trim();
+ const realIp = heads.get('x-real-ip')?.trim();
+ // Empty strings must not win the fallback chain, otherwise unrelated
+ // requests collapse into a single shared bucket.
+ const ip = forwardedFor || realIp || 'unknown';π€ Prompt for AI Agents |
||
|
|
||
| const { ipLimit = 10, ipWindowMs = 60_000, targetLimit = 5, targetWindowMs = 60_000 } = options; | ||
|
|
||
| const ipResult = rateLimit(`ip:${actionName}:${ip}`, ipLimit, ipWindowMs); | ||
| if (!ipResult.allowed) { | ||
| return ipResult; | ||
| } | ||
|
|
||
| return rateLimit(`target:${actionName}:${targetId.toLowerCase()}`, targetLimit, targetWindowMs); | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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
r8is blocked by the IP limit, not the target limit.The test sets
ipLimit: 5for requests from5.6.7.8. Requestsr4throughr7consume 4 IP hits, andr8consumes the fifth check against a bucket that already holds 4... The count reaches the IP threshold at the same request that reaches the target threshold. The assertion passes, but it does not prove thattargetLimitcaused the block. The comment on Line 132 claims a target-limit block.Raise
ipLimitfor the later requests so only the target limit can fire.π Proposed fix to isolate the target limit
// 6th target hit overall: should be blocked by targetLimit const r8 = await rateLimitDual('login', 'user@example.com', { - ipLimit: 5, + ipLimit: 50, // high enough that only targetLimit can block targetLimit: 5, }); expect(r8.allowed).toBe(false);π Committable suggestion
π€ Prompt for AI Agents