Skip to content

🛡️ Sentinel: [security improvement] Dual Rate-Limiting - #120

Open
projectamazonph wants to merge 2 commits into
mainfrom
fix/sentinel-dual-rate-limiting-3799331359200161410
Open

🛡️ Sentinel: [security improvement] Dual Rate-Limiting#120
projectamazonph wants to merge 2 commits into
mainfrom
fix/sentinel-dual-rate-limiting-3799331359200161410

Conversation

@projectamazonph

@projectamazonph projectamazonph commented Aug 9, 2026

Copy link
Copy Markdown
Owner

🛡️ Sentinel: [security improvement] Dual Rate-Limiting for Auth Actions

🚨 Severity: MEDIUM / SECURITY ENHANCEMENT

💡 Vulnerability:
Critical authentication actions (signUpAction and signInAction) previously used target-only (email) rate-limiting buckets. This opened the system up to:

  1. Account Lockout Denial of Service (DoS): Attackers could repeatedly brute-force or trigger authentication requests against a target's email address, locking legitimate users out of their accounts.
  2. Distributed Credential Stuffing: Attackers could run distributed credential stuffing campaigns targeting multiple email addresses from a single IP address without triggering the email-level rate limiter.

🎯 Impact:
An attacker could cause widespread account lockout for target users, or exhaust system resources by stuffing credentials across multiple accounts without IP-level restrictions.

🔧 Fix:

  1. Implemented a dual rate-limiting helper rateLimitDual inside src/lib/rate-limit.ts using Next.js 15 asynchronous headers().
  2. Structured the dual rate-limiting check to always evaluate and block on client IP before checking or incrementing target-based (email) buckets. This successfully prevents malicious IP-blocked actors from polluting target-level limits.
  3. Added a clean resetRateLimits() helper to clear rate-limiting buckets and prevent inter-test pollution in Vitest.
  4. Upgraded signUpAction and signInAction inside src/app/actions/auth.ts to call rateLimitDual asynchronously.
  5. Added comprehensive, targeted unit tests in src/lib/__tests__/rate-limit.test.ts to assert that:
    • Normal requests within limits succeed.
    • IP rate limits are checked first and block further requests.
    • Blocked requests do not pollute or increment the target email's bucket.
    • Headers split and trim IP addresses securely.

Verification:

  • Full test suite passed (216 tests passed).
  • TypeScript check (pnpm typecheck) completed successfully with 0 errors.
  • ESLint (pnpm lint) completed successfully with 0 errors.

PR created automatically by Jules for task 3799331359200161410 started by @projectamazonph

Summary by CodeRabbit

  • Security Improvements

    • Strengthened sign-up and sign-in rate limiting with both IP-based and account-targeted protections.
    • Blocked requests are prevented from consuming account-target rate-limit capacity.
    • Improved handling of client IP addresses when requests pass through proxies.
  • Tests

    • Added comprehensive coverage for rate-limit enforcement, blocking behavior, and IP extraction.

Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 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 @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

Copilot AI lite review requested due to automatic review settings August 9, 2026 13:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@projectamazonph, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b70e435-84ad-4921-a477-e9cfa4ea7c3b

📥 Commits

Reviewing files that changed from the base of the PR and between cf816ff and 4a6a9c9.

📒 Files selected for processing (1)
  • package.json
📝 Walkthrough

Walkthrough

Authentication signup and signin now use dual IP- and target-based rate limiting. The implementation detects client IPs from request headers, checks IP limits before target limits, and adds tests for enforcement, state isolation, and forwarded-IP parsing.

Changes

Dual authentication rate limiting

Layer / File(s) Summary
Dual rate-limit implementation
src/lib/rate-limit.ts
Adds rateLimitDual and resetRateLimits. The function resolves the client IP and applies IP-based limiting before target-based limiting.
Authentication action integration
src/app/actions/auth.ts, src/app/actions/__tests__/auth-actions.test.ts
Signup and signin use rateLimitDual with email targets. The authentication test mock provides asynchronous request headers.
Rate-limit validation and security record
src/lib/__tests__/rate-limit.test.ts, .jules/sentinel.md
Tests cover IP blocking, target enforcement, blocked-request handling, state reset, and forwarded-IP parsing. The Sentinel Journal records the dual-limit requirement.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: copilot

Sequence Diagram(s)

sequenceDiagram
  participant AuthAction
  participant RequestHeaders
  participant RateLimiter
  AuthAction->>RequestHeaders: Read client IP headers
  RequestHeaders-->>AuthAction: Return first forwarded IP or fallback
  AuthAction->>RateLimiter: Check IP limit
  RateLimiter-->>AuthAction: Deny or continue
  AuthAction->>RateLimiter: Check lowercased target limit
  RateLimiter-->>AuthAction: Return rate-limit result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding dual rate limiting as a security improvement.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sentinel-dual-rate-limiting-3799331359200161410

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/app/actions/__tests__/auth-actions.test.ts (1)

32-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reset the rate-limit buckets between tests in this file.

The mock returns null for every header, so all calls resolve to the '127.0.0.1' fallback bucket. The buckets are module-level state that persists across tests in the file. The current test count stays under ipLimit = 10 per action, so the suite passes. When someone adds more signInAction or signUpAction cases, the limiter blocks them and the failures look unrelated to the new test.

Call resetRateLimits() in beforeEach to remove this hidden coupling.

♻️ Proposed test isolation
+import { resetRateLimits } from '`@/lib/rate-limit`';
+
+beforeEach(() => {
+  resetRateLimits();
+});
🤖 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/__tests__/auth-actions.test.ts` around lines 32 - 34, Update
the test setup in auth-actions.test.ts to call resetRateLimits() from a
beforeEach hook, clearing the module-level rate-limit buckets before every test
while preserving the existing header mock and test behavior.
src/lib/__tests__/rate-limit.test.ts (2)

140-152: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for the x-real-ip and no-header paths.

The suite covers x-forwarded-for only. Two branches of the IP resolution in src/lib/rate-limit.ts Line 80 stay untested: the x-real-ip fallback and the '127.0.0.1' default when no header is present. Both affect which bucket a request lands in, so a regression there is silent.

💚 Proposed additional cases
+  it('falls back to x-real-ip when x-forwarded-for is absent', async () => {
+    mockHeadersMap.set('x-real-ip', '9.9.9.9');
+    expect((await rateLimitDual('login', 'user@example.com', { ipLimit: 1 })).allowed).toBe(true);
+    expect((await rateLimitDual('login', 'user@example.com', { ipLimit: 1 })).allowed).toBe(false);
+  });
+
+  it('uses a single fallback bucket when no IP header is present', async () => {
+    expect((await rateLimitDual('login', 'a@example.com', { ipLimit: 1 })).allowed).toBe(true);
+    expect((await rateLimitDual('login', 'b@example.com', { ipLimit: 1 })).allowed).toBe(false);
+  });
🤖 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 140 - 152, Add tests in
the rate-limit suite for IP resolution when only x-real-ip is set and when
neither IP header is present, exercising rateLimitDual and verifying repeated
requests use the same bucket and enforce the configured limit. Clear the mock
headers between cases so each test validates the x-real-ip fallback and the
127.0.0.1 default independently.

58-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the self-dialogue from the comments.

Lines 65-69 contain working notes ("Wait! Let's double check this logic", "This is correct!"). Line 65 also contradicts lines 58-62: line 61 says the request is allowed, line 65 says it is blocked. The assertion on Line 74 expects false, which matches Line 65. Keep one accurate statement.

♻️ Proposed comment cleanup
-    // 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).
+    // A different IP (5.6.7.8) now tries the same target. Its IP bucket is
+    // empty, so only the target bucket can block it.
     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!
+    // The target bucket holds 2 hits from res1 and res2, which reaches
+    // targetLimit. The blocked res3 added nothing.
🤖 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 58 - 69, Clean up the
comments in the rate-limit test around the mockHeadersMap update by removing the
self-dialogue and contradictory explanation. Keep one accurate statement
consistent with the assertion that the first request from 5.6.7.8 is blocked
because the target bucket has already reached targetLimit.
🤖 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/lib/__tests__/rate-limit.test.ts`:
- Around line 126-137: Update the later rateLimitDual calls for r7 and r8 in the
test so their ipLimit is higher than the accumulated requests from 5.6.7.8,
while keeping targetLimit at 5. This isolates the target-limit behavior and
ensures r8 is blocked specifically by targetLimit.

In `@src/lib/rate-limit.ts`:
- Around line 78-80: Harden IP resolution in the rate-limit flow around
headers() by using only a trusted platform IP header or a configured
trusted-proxy offset from x-forwarded-for; do not trust the first
client-controlled entry by default. Normalize trimmed empty values to null,
allow fallback to x-real-ip, and remove the shared 127.0.0.1 fallback by failing
closed or applying the established no-IP handling/logging path when no address
is available.

---

Nitpick comments:
In `@src/app/actions/__tests__/auth-actions.test.ts`:
- Around line 32-34: Update the test setup in auth-actions.test.ts to call
resetRateLimits() from a beforeEach hook, clearing the module-level rate-limit
buckets before every test while preserving the existing header mock and test
behavior.

In `@src/lib/__tests__/rate-limit.test.ts`:
- Around line 140-152: Add tests in the rate-limit suite for IP resolution when
only x-real-ip is set and when neither IP header is present, exercising
rateLimitDual and verifying repeated requests use the same bucket and enforce
the configured limit. Clear the mock headers between cases so each test
validates the x-real-ip fallback and the 127.0.0.1 default independently.
- Around line 58-69: Clean up the comments in the rate-limit test around the
mockHeadersMap update by removing the self-dialogue and contradictory
explanation. Keep one accurate statement consistent with the assertion that the
first request from 5.6.7.8 is blocked because the target bucket has already
reached targetLimit.
🪄 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: dd0d621e-7a75-4251-9eb3-71a9cf30be08

📥 Commits

Reviewing files that changed from the base of the PR and between 9d0e0bf and cf816ff.

📒 Files selected for processing (5)
  • .jules/sentinel.md
  • src/app/actions/__tests__/auth-actions.test.ts
  • src/app/actions/auth.ts
  • src/lib/__tests__/rate-limit.test.ts
  • src/lib/rate-limit.ts

Comment on lines +126 to +137
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);

Copy link
Copy Markdown

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

r8 is blocked by the IP limit, not the target limit.

The test sets ipLimit: 5 for requests from 5.6.7.8. Requests r4 through r7 consume 4 IP hits, and r8 consumes 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 that targetLimit caused the block. The comment on Line 132 claims a target-limit block.

Raise ipLimit for 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
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: 50, // high enough that only targetLimit can block
targetLimit: 5,
});
expect(r8.allowed).toBe(false);
🤖 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 126 - 137, Update the
later rateLimitDual calls for r7 and r8 in the test so their ipLimit is higher
than the accumulated requests from 5.6.7.8, while keeping targetLimit at 5. This
isolates the target-limit behavior and ensures r8 is blocked specifically by
targetLimit.

Comment thread src/lib/rate-limit.ts
Comment on lines +78 to +80
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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

  1. x-forwarded-for is client controllable. If the deployment does not sit behind a proxy that overwrites this header, an attacker sets a new value on each request and bypasses the IP bucket completely. Read the IP from a trusted source (platform-provided IP header, or take the entry at a known trusted-proxy offset from the right).
  2. An input like ',' produces an empty string. '' is not nullish, so the ?? chain does not fall through to x-real-ip, and every such request shares the bucket key ip:<action>:.
  3. The '127.0.0.1' fallback also merges all header-less requests into one bucket. With ipLimit = 10 per minute, a deployment that loses these headers rate-limits all users together.

Normalize to null on empty values, and consider failing closed or logging when no IP can be resolved.

🔒 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
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 78 - 80, Harden IP resolution in the
rate-limit flow around headers() by using only a trusted platform IP header or a
configured trusted-proxy offset from x-forwarded-for; do not trust the first
client-controlled entry by default. Normalize trimmed empty values to null,
allow fallback to x-real-ip, and remove the shared 127.0.0.1 fallback by failing
closed or applying the established no-IP handling/logging path when no address
is available.

…oken pnpm release pin in CI

Co-authored-by: projectamazonph <286085559+projectamazonph@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants