🛡️ Sentinel: implement dual rate limiting for authentication actions - #124
🛡️ Sentinel: implement dual rate limiting for authentication actions#124projectamazonph wants to merge 2 commits into
Conversation
…nt lockout DoS Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com>
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
|
Warning Review limit reached
Next review available in: 54 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. 📝 WalkthroughWalkthroughAuthentication signup and signin actions now use IP-first dual rate limiting. The limiter extracts client IPs from request headers, enforces separate IP and target limits, and includes expanded tests. ChangesDual rate limiting
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant AuthAction
participant rateLimitDual
participant RequestHeaders
participant RateLimitBuckets
AuthAction->>rateLimitDual: Check signup or signin target
rateLimitDual->>RequestHeaders: Read client IP headers
RequestHeaders-->>rateLimitDual: Return client IP or unknown
rateLimitDual->>RateLimitBuckets: Check IP limit
RateLimitBuckets-->>rateLimitDual: Allow or reject
rateLimitDual->>RateLimitBuckets: Check target limit when IP is allowed
RateLimitBuckets-->>rateLimitDual: Allow or reject
rateLimitDual-->>AuthAction: Return rate-limit result
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/actions/auth.ts`:
- Line 35: Set the signup rate-limit configuration at src/app/actions/auth.ts
lines 35-35 so the IP quota is lower than the target quota, or adjust the
target-bucket policy accordingly; apply the same safe relationship at
src/app/actions/auth.ts lines 128-128 for signin. Update .jules/sentinel.md
lines 3-6 to state that IP-first ordering reduces lockout risk only when the IP
quota blocks the source before the target quota is exhausted.
In `@src/lib/__tests__/rate-limit.test.ts`:
- Around line 61-120: The rateLimitDual tests lack coverage proving target keys
are normalized consistently. Add a test in the rateLimitDual suite that calls
rateLimitDual with the same target using different casing and verifies the
second call is rejected or otherwise shares the first call’s target bucket under
a target limit of one.
In `@src/lib/rate-limit.ts`:
- Around line 66-70: The rateLimitDual client-IP selection currently assigns
headerless requests to a shared unknown bucket. Update rateLimitDual to use a
platform-provided or validated client IP and skip IP-based limiting when no
trusted address is available; preserve other rate-limit behavior. In
src/lib/__tests__/rate-limit.test.ts lines 114-119, add a regression test
covering repeated headerless requests and verifying they are not blocked by the
shared IP bucket.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8893f307-109f-41b7-a3db-85c8eeea8509
📒 Files selected for processing (5)
.jules/sentinel.mdsrc/app/actions/__tests__/auth-actions.test.tssrc/app/actions/auth.tssrc/lib/__tests__/rate-limit.test.tssrc/lib/rate-limit.ts
|
|
||
| export const signUpAction = createSafeAction(signUpSchema, async (data) => { | ||
| const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000); | ||
| const rl = await rateLimitDual('signup', data.email, 10, 5, 60_000); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Set the IP limit below the target limit.
With ipLimit = 10 and targetLimit = 5, one IP can submit five requests for a victim email and exhaust its target bucket before the IP bucket blocks it. IP-first ordering does not prevent this lockout.
src/app/actions/auth.ts#L35-L35: set the signup IP limit below the target limit, or change the target-bucket policy.src/app/actions/auth.ts#L128-L128: apply the same safe limit relationship to signin..jules/sentinel.md#L3-L6: state that IP-first ordering reduces lockout risk only when the IP quota blocks the source before it exhausts the target quota.
📍 Affects 2 files
src/app/actions/auth.ts#L35-L35(this comment)src/app/actions/auth.ts#L128-L128.jules/sentinel.md#L3-L6
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/actions/auth.ts` at line 35, Set the signup rate-limit configuration
at src/app/actions/auth.ts lines 35-35 so the IP quota is lower than the target
quota, or adjust the target-bucket policy accordingly; apply the same safe
relationship at src/app/actions/auth.ts lines 128-128 for signin. Update
.jules/sentinel.md lines 3-6 to state that IP-first ordering reduces lockout
risk only when the IP quota blocks the source before the target quota is
exhausted.
| describe('rateLimitDual', () => { | ||
| it('uses x-forwarded-for header for client IP rate limiting', async () => { | ||
| mockHeadersGet.mockImplementation((header: string) => { | ||
| if (header === 'x-forwarded-for') return '1.2.3.4, 5.6.7.8'; | ||
| return null; | ||
| }); | ||
|
|
||
| // Under IP limit of 2, target limit of 1 | ||
| // First attempt: should succeed for both IP and target | ||
| const res1 = await rateLimitDual('login', 'user@example.com', 2, 1, 60_000); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| // Second attempt with same email: target limit exceeded (limit is 1) | ||
| const res2 = await rateLimitDual('login', 'user@example.com', 2, 1, 60_000); | ||
| expect(res2.allowed).toBe(false); | ||
| }); | ||
|
|
||
| it('IP limit check runs BEFORE target check to prevent account lockout', async () => { | ||
| mockHeadersGet.mockImplementation((header: string) => { | ||
| if (header === 'x-forwarded-for') return '9.9.9.9'; | ||
| return null; | ||
| }); | ||
|
|
||
| // IP limit = 1, target limit = 5 | ||
| // First attempt: allowed | ||
| const res1 = await rateLimitDual('login', 'legit@example.com', 1, 5, 60_000); | ||
| expect(res1.allowed).toBe(true); | ||
|
|
||
| // Second attempt: IP blocked (since IP limit is 1) | ||
| const res2 = await rateLimitDual('login', 'legit@example.com', 1, 5, 60_000); | ||
| expect(res2.allowed).toBe(false); | ||
|
|
||
| // Verify target-based key did not get polluted by checking another IP | ||
| mockHeadersGet.mockImplementation((header: string) => { | ||
| if (header === 'x-forwarded-for') return '8.8.8.8'; // safe IP | ||
| return null; | ||
| }); | ||
|
|
||
| // Legit user on safe IP can still log in because their target bucket wasn't polluted by blocked IP | ||
| const res3 = await rateLimitDual('login', 'legit@example.com', 1, 5, 60_000); | ||
| expect(res3.allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('falls back to x-real-ip when x-forwarded-for is missing', async () => { | ||
| mockHeadersGet.mockImplementation((header: string) => { | ||
| if (header === 'x-real-ip') return '127.0.0.1'; | ||
| return null; | ||
| }); | ||
|
|
||
| const res = await rateLimitDual('login', 'user@example.com', 1, 1, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
| }); | ||
|
|
||
| it('falls back to unknown when both headers are missing', async () => { | ||
| mockHeadersGet.mockReturnValue(null); | ||
|
|
||
| const res = await rateLimitDual('login', 'user@example.com', 1, 1, 60_000); | ||
| expect(res.allowed).toBe(true); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add a target-key normalization regression test.
Call rateLimitDual with the same target in mixed case. Assert that the second call reaches the same target bucket.
As per coding guidelines, “New features must include tests.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/__tests__/rate-limit.test.ts` around lines 61 - 120, The
rateLimitDual tests lack coverage proving target keys are normalized
consistently. Add a test in the rateLimitDual suite that calls rateLimitDual
with the same target using different casing and verifies the second call is
rejected or otherwise shares the first call’s target bucket under a target limit
of one.
Source: Coding guidelines
| const xff = reqHeaders.get('x-forwarded-for'); | ||
| const xri = reqHeaders.get('x-real-ip'); | ||
|
|
||
| // Safely extract client IP from x-forwarded-for (handling noUncheckedIndexedAccess) | ||
| const clientIp = (xff ? xff.split(',')[0]?.trim() : null) || xri || 'unknown'; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -i -C 3 \
'x-forwarded-for|x-real-ip|trust proxy|remoteAddress|forwarded' \
-g '!node_modules/**' -g '!**/.next/**' .Repository: projectamazonph/amph-v2
Length of output: 4376
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- rate-limit structure ---'
ast-grep outline src/lib/rate-limit.ts --view expanded
echo '--- rate-limit implementation ---'
cat -n src/lib/rate-limit.ts | sed -n '1,150p'
echo '--- rate-limit test structure and relevant tests ---'
ast-grep outline src/lib/__tests__/rate-limit.test.ts --view expanded
cat -n src/lib/__tests__/rate-limit.test.ts | sed -n '1,145p'
echo '--- rateLimitDual call sites ---'
rg -n -C 5 'rateLimitDual|rateLimit\(' srcRepository: projectamazonph/amph-v2
Length of output: 22721
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- all proxy and client-address references ---'
rg -n -i -C 3 \
'x-forwarded-for|x-real-ip|forwarded|remoteAddress|trusted.?proxy|proxy.?trust|vercel|cloudflare|load.?balancer|ingress' \
--glob '!node_modules/**' --glob '!**/.next/**' .
echo '--- deployment and framework configuration files ---'
git ls-files | rg '(^|/)(vercel\.json|next\.config\..*|middleware\..*|package\.json|Dockerfile.*|docker-compose.*|README.*|.*security.*|.*audit.*)$' | sort
echo '--- deterministic model of the headerless path ---'
python3 - <<'PY'
from collections import defaultdict
def simulate(requests: int, ip_limit: int, target_limit: int):
buckets = defaultdict(int)
results = []
for _ in range(requests):
ip_key = "ip:login:unknown"
if buckets[ip_key] >= ip_limit:
results.append(False)
continue
buckets[ip_key] += 1
target_key = "target:login:user@example.com"
if buckets[target_key] >= target_limit:
results.append(False)
continue
buckets[target_key] += 1
results.append(True)
return results, dict(buckets)
results, buckets = simulate(11, 10, 100)
print({"allowed_count": sum(results), "request_10": results[9], "request_11": results[10], "buckets": buckets})
PYRepository: projectamazonph/amph-v2
Length of output: 50381
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- all proxy and client-address references ---'
rg -n -i -C 3 \
'x-forwarded-for|x-real-ip|forwarded|remoteAddress|trusted.?proxy|proxy.?trust|vercel|cloudflare|load.?balancer|ingress' \
--glob '!node_modules/**' --glob '!**/.next/**' .
echo '--- deployment and framework configuration files ---'
git ls-files | rg '(^|/)(vercel\.json|next\.config\..*|middleware\..*|package\.json|Dockerfile.*|docker-compose.*|README.*|.*security.*|.*audit.*)$' | sort
echo '--- deterministic model of the headerless path ---'
python3 - <<'PY'
from collections import defaultdict
def simulate(requests: int, ip_limit: int, target_limit: int):
buckets = defaultdict(int)
results = []
for _ in range(requests):
ip_key = "ip:login:unknown"
if buckets[ip_key] >= ip_limit:
results.append(False)
continue
buckets[ip_key] += 1
target_key = "target:login:user@example.com"
if buckets[target_key] >= target_limit:
results.append(False)
continue
target_key = "target:login:user@example.com"
if buckets[target_key] >= target_limit:
results.append(False)
continue
target_key = "target:login:user@example.com"
buckets[target_key] += 1
results.append(True)
return results, dict(buckets)
results, buckets = simulate(11, 10, 100)
print({"allowed_count": sum(results), "request_10": results[9], "request_11": results[10], "buckets": buckets})
PYRepository: projectamazonph/amph-v2
Length of output: 50381
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- exact deployment/framework files ---'
for f in $(git ls-files | rg '(^|/)(vercel\.json|next\.config\..*|middleware\..*|Dockerfile.*|docker-compose.*)$'); do
echo "### $f"
cat -n "$f"
done
echo '--- focused proxy references outside generated/lock/status files ---'
rg -n -i \
'x-forwarded-for|x-real-ip|forwarded|remoteAddress|trusted.?proxy|proxy.?trust|client.?ip' \
src next.config.ts middleware.ts middleware.js vercel.json 2>/dev/null || true
echo '--- deterministic limiter model ---'
python3 - <<'PY'
from collections import defaultdict
def rate_limit(buckets, key, limit):
if buckets[key] >= limit:
return False
buckets[key] += 1
return True
buckets = defaultdict(int)
results = []
for _ in range(11):
if not rate_limit(buckets, "ip:signin:unknown", 10):
results.append(False)
continue
results.append(rate_limit(buckets, "target:signin:user@example.com", 100))
print("allowed requests:", sum(results))
print("request 10 allowed:", results[9])
print("request 11 allowed:", results[10])
print("unknown IP hits:", buckets["ip:signin:unknown"])
PYRepository: projectamazonph/amph-v2
Length of output: 11719
🌐 Web query:
Vercel documentation x-forwarded-for x-real-ip client supplied spoofing trusted IP headers
💡 Result:
Vercel takes specific measures to prevent IP spoofing by managing request headers like X-Forwarded-For and X-Real-IP [1]. Key points regarding Vercel's handling of these headers include: 1. Automatic Overwriting: To prevent IP spoofing, Vercel overwrites the standard X-Forwarded-For header for requests processed by its platform [1]. Consequently, you cannot rely on this header to pass external, client-supplied IP addresses directly to a Vercel deployment, as they will be replaced by the public IP address of the client as seen by Vercel's network [1]. 2. Equivalent Headers: The X-Real-IP header is treated as identical to X-Forwarded-For and is similarly managed by Vercel [1]. Additionally, Vercel provides x-vercel-forwarded-for, which contains the same information but is specifically intended for use in environments where you might need to distinguish the original IP from headers that could be overwritten by upstream proxies [1]. 3. Reverse Proxies and Verified Proxy: Vercel generally discourages placing a reverse proxy in front of a Vercel project because it obscures the end-user IP and reduces the effectiveness of Vercel's firewall [2]. However, if you must use a reverse proxy, Vercel offers a feature called Verified Proxy [3][4]. This system allows Vercel to trust specific headers (such as CF-Connecting-IP, True-Client-IP, or X-GCP-Connecting-IP) passed from known, verified providers to correctly identify the real client IP [3][4]. 4. Security Implications: Because client-supplied X-Forwarded-For headers are untrusted, any application logic relying on these headers for security, rate limiting, or access control is inherently vulnerable to spoofing unless handled through the Verified Proxy configuration for known, static infrastructure [2][3]. For applications requiring access to the original client IP, you should use the headers provided by Vercel's network or ensure your infrastructure is correctly onboarded to the Verified Proxy program if using an external load balancer or CDN [3][4].
Citations:
- 1: https://vercel.com/docs/headers/request-headers
- 2: https://vercel.com/docs/security/reverse-proxy
- 3: https://vercel.com/kb/guide/how-to-setup-verified-proxy
- 4: https://community.vercel.com/t/how-to-enable-trusted-proxy-on-vercel/1956
Avoid the shared unknown IP bucket.
When both headers are absent, rateLimitDual blocks all headerless clients after ten requests in the same instance. Use a platform-provided or validated client IP, and skip the IP bucket when no trusted address exists. Add a repeated headerless-request regression test.
📍 Affects 2 files
src/lib/rate-limit.ts#L66-L70(this comment)src/lib/__tests__/rate-limit.test.ts#L114-L119
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/rate-limit.ts` around lines 66 - 70, The rateLimitDual client-IP
selection currently assigns headerless requests to a shared unknown bucket.
Update rateLimitDual to use a platform-provided or validated client IP and skip
IP-based limiting when no trusted address is available; preserve other
rate-limit behavior. In src/lib/__tests__/rate-limit.test.ts lines 114-119, add
a regression test covering repeated headerless requests and verifying they are
not blocked by the shared IP bucket.
…nt lockout DoS Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com>
🚨 Severity
MEDIUM / SECURITY ENHANCEMENT
💡 Vulnerability
Simple single rate limiting based purely on user-specific identifiers (like target emails) can be abused by malicious actors to lock legitimate users out of their accounts (Account Lockout DoS).
🎯 Impact
Without dual rate limiting, an attacker can continuously send bad login/signup attempts for a victim's email, triggering the target rate limiter and blocking the legitimate user from logging in or registering.
🔧 Fix
rateLimitDualinsrc/lib/rate-limit.tsto check client IP-based rate limits before checking target-based keys (such as email). This ensures malicious IPs are blocked first, protecting the target's bucket from exhaustion.x-forwarded-forandx-real-ip) safely for IP extraction, consideringnoUncheckedIndexedAccesstype safety.signUpActionandsignInActioninsrc/app/actions/auth.tsto userateLimitDualasynchronously.resetRateLimits()to prevent test pollution.src/lib/__tests__/rate-limit.test.tsto verify sliding-window behavior, fallback IPs, and dual execution order.✅ Verification
PR created automatically by Jules for task 18249317339137429242 started by @projectamazonph
Summary by CodeRabbit
Bug Fixes
Tests