Skip to content

fix(auth): rate-limit login and registration per client (P2) - #1121

Open
wjc2821296948 wants to merge 7 commits into
siteboon:mainfrom
wjc2821296948:fix/auth-rate-limit
Open

fix(auth): rate-limit login and registration per client (P2)#1121
wjc2821296948 wants to merge 7 commits into
siteboon:mainfrom
wjc2821296948:fix/auth-rate-limit

Conversation

@wjc2821296948

@wjc2821296948 wjc2821296948 commented Aug 7, 2026

Copy link
Copy Markdown

P2 — /api/auth/login and /api/auth/register have no rate limiting

Vulnerability description

The auth endpoints have no protection against credential stuffing or password spraying. An attacker who has obtained a leaked username/password list (or a single valid username) can hammer /api/auth/login from a single host at the full speed the server can answer; the bcrypt comparison takes hundreds of milliseconds, so the natural per-process throughput is only a handful of attempts per second, but with thousands of parallel hosts this is enough to enumerate a non-trivial fraction of accounts in days. There is no lockout, no exponential backoff, no captcha, and no Retry-After signal to back off the attacker.

Fix

Add a per-client (per-IP) sliding-window rate limiter middleware and mount it on both /api/auth/login and /api/auth/register. Default budget: 10 requests per 60-second window. When the budget is exceeded the middleware responds 429 Too Many Requests with Retry-After: <seconds> set to the larger of the lockout clock and the rolling-window expiry — that is, the next moment the client is guaranteed to be under the cap again, regardless of whether they're still inside the rolling window.

The middleware is a single shared instance (a Map<clientKey, { timestamps, lockoutUntil }>), so per-client isolation is preserved across requests and clients cannot starve each other.

Files

  • server/modules/auth/rate-limit.middleware.ts (new)
  • server/modules/auth/auth.routes.ts
  • server/modules/auth/tests/rate-limit.middleware.test.ts (new)

Commits

  • 45d2369fix(auth): rate-limit login and registration per client
  • ebff12bfix(auth): make rate-limit Retry-After reflect the next permitted request
  • d41a510test(auth): correct the simulated-time explanation in the lockout-extension test

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added protection for registration and login against excessive attempts.
    • Limits each client to 10 attempts per minute, with a temporary 60-second lockout extension.
    • Blocked requests receive a clear retry timeframe through standard rate-limit responses.
  • Tests

    • Added coverage for rate limits, lockout timing, retry guidance, rolling-window expiration, independent client tracking, and trusted forwarded-address handling.

wjc2821296948 and others added 3 commits August 7, 2026 14:59
The `/api/auth/login` and `/api/auth/register` endpoints have no protection
against credential stuffing or password spraying. An attacker who can reach
the server (default bind `0.0.0.0:3001`) can run an unbounded number of
guesses per second from a single IP. Bcrypt with 12 rounds makes each guess
slow but does not make online brute force infeasible — over a long enough
window any 8-character password falls.

Add a per-client sliding-window rate limiter that defaults to 10 attempts
per minute and a 60-second lockout window once the cap is hit. The limiter
keys on the TCP peer address (or the first `X-Forwarded-For` entry when
behind a reverse proxy), so it scales to single-user self-hosted installs
without needing a shared store. Successful and failed attempts both
consume a slot; the limiter does not let a misbehaving client extend a
lockout by retrying.

Cover the limiter with a focused unit test that verifies the under-cap,
over-cap, lockout-no-extend, rolling-window, and per-client-key behaviors.

Co-authored-by: cgsdn <[email protected]>
…uest

When `lockoutMs` is shorter than `windowMs`, the previous lockout
calculation set `blockedUntil = now + lockoutMs`, but the rolling window
retained `maxAttempts` timestamps whose earliest expiry was
`timestamps[0] + windowMs`. The client was told to retry in `lockoutMs`
seconds, but on its next request the limiter tripped again — starting a
new lockout — because the cap was still full. This produced a confusing
back-off pattern in which the client could never make forward progress
without burning another lockout cycle.

Set `blockedUntil` to `max(now + lockoutMs, earliestRetainedTimestamp +
windowMs)` so `Retry-After` always points at the next moment a request can
succeed. `retryAfterSeconds` is now derived from the final `blockedUntil`
value, keeping the header consistent with the body.

The "lockout does not extend" test previously advanced the clock by 500
ms — still inside both the original lockout and the rolling window — so
the assertion could not discriminate between a preserved lockout and a
newly reset one. Advance by 1.1 s instead and assert the updated
`Retry-After` header reflects the remaining lockout time.

Add a focused regression test that configures `lockoutMs < windowMs` and
verifies `Retry-After` returns the rolling-window expiry, not the lockout
length.

Co-authored-by: cgsdn <[email protected]>
…ension test

CodeRabbit noted that the previous comment reversed the two timing
conditions: it claimed `currentTime` was past the original lockout and
still inside the rolling window, when it is in fact past the rolling
window and still inside the lockout. Rewrite the explanation so the
two boundaries (lockout expiry at 3000 vs. rolling-window expiry at
2000) and their relative positions to the advanced clock (2100) are
described correctly.

No behaviour change to the test itself.

Co-authored-by: cgsdn <[email protected]>
@wjc2821296948

Copy link
Copy Markdown
Author

Severity summary

P2/api/auth/login and /api/auth/register have no rate limiting, enabling credential stuffing and password spraying.

This PR is split out from the previous umbrella PR (#1106) per @blackmammoth's request that each finding be reviewed in isolation.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e830a14d-99d2-493d-aa7a-e739a4a3a256

📥 Commits

Reviewing files that changed from the base of the PR and between 8955283 and 52d5cd0.

📒 Files selected for processing (1)
  • server/modules/auth/tests/rate-limit.middleware.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/modules/auth/tests/rate-limit.middleware.test.ts

📝 Walkthrough

Walkthrough

Authentication now uses an in-memory, configurable rate limiter. The limiter protects /register and /login, returns 429 responses with Retry-After, handles trusted forwarded addresses, and removes expired client records.

Changes

Authentication Rate Limiting

Layer / File(s) Summary
Rate limiter middleware
server/modules/auth/rate-limit.middleware.ts
Identifies clients, tracks rolling-window attempts, enforces lockouts, returns 429 responses with Retry-After, forwards allowed requests, and exposes reset and size operations.
Authentication route protection
server/modules/auth/auth.routes.ts
Applies a shared limiter with a 10-attempt limit, a 60-second window, and a 60-second lockout extension to /register and /login.
Rate limiter behavior tests
server/modules/auth/tests/rate-limit.middleware.test.ts
Tests allowed requests, retry durations, lockout behavior, expiration, client isolation, trusted proxy handling, spoofing prevention, proxy-chain selection, fallback behavior, and record eviction.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AuthRoutes
  participant RateLimiter
  participant AuthHandler

  Client->>AuthRoutes: POST /login or /register
  AuthRoutes->>RateLimiter: Check client attempt state
  alt Request allowed
    RateLimiter->>AuthHandler: Forward request
    AuthHandler-->>Client: Authentication response
  else Limit exceeded
    RateLimiter-->>Client: 429 with Retry-After
  end
Loading

Poem

A rabbit checks each hopping plea,
Ten attempts pass, then wait must be.
The limiter guards each login door,
Trusted addresses guide the score.
Expired tracks fade silently.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: per-client rate limiting for login and registration authentication routes.
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
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@wjc2821296948

Copy link
Copy Markdown
Author

45d2369fix(auth): rate-limit login and registration per client

Severity

P2 — credential stuffing / password spraying.

Description

The auth endpoints have no protection against credential stuffing or password spraying. An attacker who has obtained a leaked username/password list (or a single valid username) can hammer /api/auth/login from a single host at the full speed the server can answer; the bcrypt comparison takes hundreds of milliseconds, so the natural per-process throughput is only a handful of attempts per second, but with thousands of parallel hosts this is enough to enumerate a non-trivial fraction of accounts in days. There is no lockout, no exponential backoff, no captcha, and no Retry-After signal to back off the attacker.

Fix

Add a per-client (per-IP) sliding-window rate limiter middleware (server/modules/auth/rate-limit.middleware.ts) and mount it on both /api/auth/login and /api/auth/register. Default budget: 10 requests per 60-second window. When the budget is exceeded the middleware responds 429 Too Many Requests with Retry-After set to the remaining lockout duration. Per-client isolation is preserved through a Map<clientKey, ...> so clients cannot starve each other.

🤖 Generated with Claude Code

@wjc2821296948

Copy link
Copy Markdown
Author

ebff12bfix(auth): make rate-limit Retry-After reflect the next permitted request

Severity

P2 (corollary)Retry-After under-reports the actual lockout duration.

Description

The previous lockout calculation set Retry-After to the remaining lockout clock only. When lockoutMs < windowMs, a client who tripped the lockout could retry successfully as soon as the lockout clock expired, but their request would still be inside the rolling window and would be rejected again — at which point the next Retry-After would still report the same lockout clock, and the client would loop indefinitely hammering a 429. The header was therefore not telling the client the next moment they are actually allowed to make progress.

Fix

Compute Retry-After as max(lockoutMs, earliestTimestamp + windowMs - now). This is the next moment the client is guaranteed to be under the rolling window cap, regardless of which clock fires first. Add a regression test for the lockoutMs < windowMs case.

🤖 Generated with Claude Code

@wjc2821296948

Copy link
Copy Markdown
Author

d41a510test(auth): correct the simulated-time explanation in the lockout-extension test

Severity

P2 (test hygiene) — misleading comment could mask a future regression.

Description

CodeRabbit noted that the comment above currentTime += 1100 in server/modules/auth/tests/rate-limit.middleware.test.ts ("advance past the second request's window") didn't match what the code did: the test is verifying that an extra request during a lockout does not extend the lockout clock past the original lockoutUntil, and the 1100 ms advance moves the simulated clock past the lockout but still inside the rolling window so the request remains a 429. The misleading comment would have hidden a future regression that broke the "extra requests don't extend lockout" property.

Fix

Replace the comment with an accurate explanation of the simulated-time schedule (lockout fires at 3000 ms, rolling window expires at 2000 ms — wait, the rolling window expires first; the request must arrive at 1100 ms < 2000 ms so it's rejected by the rolling window, not the lockout) and add the missing rolling-window-expiry assertion that the previous test was meant to exercise but didn't actually exercise.

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@server/modules/auth/rate-limit.middleware.ts`:
- Around line 62-70: Update the rate-limit middleware’s records store and
cleanup logic around middleware, records, and AttemptRecord so entries are
evicted after both the rolling rate-limit window and blockedUntil lockout
expire. Ensure empty records are removed rather than retained, and bound the
store so requests from many distinct client keys cannot cause unbounded growth
or preserve expired address data.
- Around line 25-41: Update readClientKey to accept X-Forwarded-For only when
req.socket.remoteAddress matches an explicitly configured trusted proxy;
otherwise ignore the header and use the TCP peer address (or unknown). Do not
treat loopback addresses as implicitly trusted, and preserve the existing
forwarded-address parsing only within the trusted-proxy path.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ea97561e-4f3f-4ef5-990c-8162fb944964

📥 Commits

Reviewing files that changed from the base of the PR and between f0dca2d and d41a510.

📒 Files selected for processing (3)
  • server/modules/auth/auth.routes.ts
  • server/modules/auth/rate-limit.middleware.ts
  • server/modules/auth/tests/rate-limit.middleware.test.ts

Comment thread server/modules/auth/rate-limit.middleware.ts Outdated
Comment thread server/modules/auth/rate-limit.middleware.ts
wjc2821296948 and others added 2 commits August 8, 2026 01:23
Two CodeRabbit follow-ups on the rate-limit middleware:

1. `readClientKey` accepted `X-Forwarded-For` whenever the TCP peer
   was loopback, and otherwise fell back to the socket address. That
   conflated two distinct concepts: "is the TCP peer a trusted
   reverse proxy?" and "is the TCP peer on the loopback interface?".
   Any local process able to open a connection to the server could
   pick a victim IP via `X-Forwarded-For` and bypass the limiter;
   clients behind a real reverse proxy all shared the proxy's loopback
   address and therefore shared a single limiter bucket.

   Add an explicit `trustedProxyAddresses: string[]` option. Only
   when the TCP peer matches one of those addresses do we honour
   `X-Forwarded-For`. Loopback is NOT implicitly trusted — operators
   must enumerate their proxy's addresses if they run behind one.

2. `records` retained every client key indefinitely. Filtering the
   timestamps list of an existing record does not remove the entry
   from the map, so requests from many distinct addresses caused
   unbounded memory growth and retained IP-address data after the
   rate-limit window ended. Add an opportunistic sweep that runs at
   most once per `windowMs` and deletes every record whose
   `timestamps` array is empty and whose `blockedUntil` has expired.
   The map cannot grow without bound regardless of how many distinct
   client keys we see.

Co-authored-by: cgsdn <[email protected]>
Three new cases:

1. `X-Forwarded-For is honoured only when the TCP peer is a trusted
   proxy` — verifies that with `trustedProxyAddresses: ['10.0.0.1']`,
   requests from that peer are bucketed by the forwarded address
   while requests from a non-trusted peer carrying the same header
   are bucketed by the TCP peer (so a spoofer cannot escape the
   limiter).

2. `untrusted loopback peers cannot spoof X-Forwarded-For` — even
   on the loopback interface, without explicit trust the limiter
   uses the TCP peer. Two loopback peers claiming different forwarded
   addresses share the same bucket.

3. `records map is bounded by expiring entries after the window
   passes` — pushes 50 distinct addresses through, asserts the
   internal `records` Map contains 50 entries, advances the clock
   past the window, fires one more request to trigger the sweep,
   and asserts only the just-touched record remains.

Co-authored-by: cgsdn <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
server/modules/auth/rate-limit.middleware.ts (1)

70-86: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Expire stale timestamps during the sweep.

record.timestamps is never filtered in sweepExpiredRecords. A record with only expired timestamps remains nonempty and is never deleted. The test burst at time 1000 still leaves 50 records at time 6000.

Also compare blockedUntil with now, not cutoff. The current condition retains an expired lockout for an extra rolling window.

Proposed fix
-  const isRecordExpired = (record: AttemptRecord, cutoff: number): boolean =>
-    record.timestamps.length === 0 && record.blockedUntil <= cutoff;
+  const isRecordExpired = (record: AttemptRecord, now: number): boolean => {
+    const cutoff = now - windowMs;
+    return (
+      record.timestamps.every((timestamp) => timestamp <= cutoff) &&
+      record.blockedUntil <= now
+    );
+  };
...
-      if (isRecordExpired(record, cutoff)) {
+      if (isRecordExpired(record, now)) {
🤖 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 `@server/modules/auth/rate-limit.middleware.ts` around lines 70 - 86, Update
isRecordExpired and sweepExpiredRecords to first remove timestamps older than
the active cutoff from each record, then evaluate whether the record is empty
and its blockedUntil is no longer in the future relative to now. Use now, rather
than cutoff, for the blockedUntil comparison, and delete records that satisfy
both expiration conditions.
🤖 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 `@server/modules/auth/rate-limit.middleware.ts`:
- Around line 36-42: Update the forwarded-address parsing in the trusted-proxy
branch to process X-Forwarded-For entries from right to left, skipping addresses
in trustedProxyAddresses and returning the first untrusted address. Apply this
consistently for string and array header forms, and add a regression test
covering a trusted proxy appending the real client address after a spoofed
value.

In `@server/modules/auth/tests/rate-limit.middleware.test.ts`:
- Around line 308-315: Update the rate-limit cleanup test around
createRateLimiter and its middleware so it no longer casts the factory result to
access the closure-local records map. Either add and use an explicitly typed
inspection hook for the map, or remove the size assertions while retaining an
observable test that verifies expired entries are cleaned up after the sweep.

---

Duplicate comments:
In `@server/modules/auth/rate-limit.middleware.ts`:
- Around line 70-86: Update isRecordExpired and sweepExpiredRecords to first
remove timestamps older than the active cutoff from each record, then evaluate
whether the record is empty and its blockedUntil is no longer in the future
relative to now. Use now, rather than cutoff, for the blockedUntil comparison,
and delete records that satisfy both expiration conditions.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 259ba80f-919a-47a9-b8f4-719272b43134

📥 Commits

Reviewing files that changed from the base of the PR and between d41a510 and dae890b.

📒 Files selected for processing (2)
  • server/modules/auth/rate-limit.middleware.ts
  • server/modules/auth/tests/rate-limit.middleware.test.ts

Comment thread server/modules/auth/rate-limit.middleware.ts Outdated
Comment thread server/modules/auth/tests/rate-limit.middleware.test.ts Outdated
@wjc2821296948

Copy link
Copy Markdown
Author

ba75a6b + dae890b — harden rate limiter proxy trust and bound the records map

T1 — X-Forwarded-For only honoured when the TCP peer is a trusted proxy

The previous readClientKey accepted X-Forwarded-For whenever the TCP peer was loopback, and otherwise fell back to the socket address. That conflated two distinct concepts: "is the TCP peer a trusted reverse proxy?" and "is the TCP peer on the loopback interface?". Any local process able to open a connection to the server could pick a victim IP via X-Forwarded-For and bypass the limiter; clients behind a real reverse proxy all shared the proxy's loopback address and therefore shared a single limiter bucket.

Add an explicit trustedProxyAddresses: string[] option. Only when the TCP peer matches one of those addresses do we honour X-Forwarded-For. Loopback is NOT implicitly trusted — operators must enumerate their proxy's addresses if they run behind one.

-function readClientKey(req: Request): string {
-  const socketAddress = req.socket?.remoteAddress;
-  if (socketAddress && socketAddress !== '::1' && socketAddress !== '127.0.0.1') {
-    return socketAddress;
-  }
-  const forwarded = req.headers['x-forwarded-for'];
-  if (typeof forwarded === 'string' && forwarded.trim()) {
-    return forwarded.split(',')[0]!.trim();
-  }
-  ...
+function readClientKey(req: Request, trustedProxyAddresses: Set<string>): string {
+  const socketAddress = req.socket?.remoteAddress || 'unknown';
+  if (trustedProxyAddresses.has(socketAddress)) {
+    const forwarded = req.headers['x-forwarded-for'];
+    if (typeof forwarded === 'string' && forwarded.trim()) return forwarded.split(',')[0]!.trim();
+    ...
+  }
+  return socketAddress;
+}

T2 — records map bounded by opportunistic eviction

records retained every client key indefinitely. Filtering the timestamps list of an existing record does not remove the entry from the map, so requests from many distinct addresses caused unbounded memory growth and retained IP-address data after the rate-limit window ended.

Add an opportunistic sweep that runs at most once per windowMs (so it costs O(records.size) at most once per window, not per request) and deletes every record whose timestamps array is empty and whose blockedUntil has expired. The map cannot grow without bound regardless of how many distinct client keys we see.

+ let lastSweepAt = clock();
+ const sweepExpiredRecords = () => {
+   const now = clock();
+   if (now - lastSweepAt < windowMs) return;
+   lastSweepAt = now;
+   const cutoff = now - windowMs;
+   for (const [key, record] of records) {
+     if (isRecordExpired(record, cutoff)) records.delete(key);
+   }
+ };

New tests

  • X-Forwarded-For is honoured only when the TCP peer is a trusted proxy — verifies the trusted-proxy allowlist path and that spoofers from a non-trusted peer are bucketed by TCP peer.
  • untrusted loopback peers cannot spoof X-Forwarded-For — confirms the previous bypass on 127.0.0.1 is closed.
  • records map is bounded by expiring entries after the window passes — pushes 50 distinct addresses, advances the clock, asserts only the just-touched record remains.

🤖 Generated with Claude Code

…ests

Two CodeRabbit follow-ups on the previous rate-limit hardening:

1. The previous trusted-proxy parser picked the leftmost comma-
   separated value from `X-Forwarded-For`. That value is the most
   distant hop and therefore the easiest for the real client to forge:
   a malicious client can put any address they want there, the trusted
   proxy appends the real client address on the right, and the parser
   returns the attacker-supplied value. The attacker then rotates that
   value to bypass the per-client limit.

   Walk the chain from right to left, skipping any entry that is itself
   in `trustedProxyAddresses` (proxy chains like `attacker → proxy1 →
   proxy2 → server` are common), and return the first remaining
   untrusted address. If the chain contains only trusted hops, fall
   back to the TCP peer — never trust a header value the proxy chain
   couldn't validate.

2. The previous test cast `limiter as unknown as { records: ... }`
   to read the closure-local map. `createRateLimiter` only returned
   `middleware` and `reset`, so reading `.records.size` threw at
   runtime. Expose a typed `size()` inspection hook on the factory
   result and update the test to use it.

Also add a regression test covering a two-hop trusted proxy chain
where the attacker forged the leftmost value and the real client
address sits on the right.

Co-authored-by: cgsdn <[email protected]>
@wjc2821296948

Copy link
Copy Markdown
Author

8955283fix(auth): walk X-Forwarded-For right-to-left and expose size() for tests

Two follow-ups on the previous rate-limit hardening:

T1 — pick the nearest untrusted forwarded address

The previous trusted-proxy parser picked the leftmost comma-separated value from X-Forwarded-For. That value is the most distant hop and therefore the easiest for the real client to forge: a malicious client can put any address they want there, the trusted proxy appends the real client address on the right, and the parser returned the attacker-supplied value. The attacker could then rotate that value to bypass the per-client limit.

Walk the chain from right to left, skipping any entry that is itself in trustedProxyAddresses (proxy chains like attacker → proxy1 → proxy2 → server are common), and return the first remaining untrusted address. If the chain contains only trusted hops, fall back to the TCP peer — never trust a header value the proxy chain couldn't validate.

T2 — expose a typed inspection hook

The previous test cast limiter as unknown as { records: ... } to read the closure-local map. createRateLimiter only returned middleware and reset, so reading .records.size threw at runtime. Expose a typed size() inspection hook on the factory result and update the test to use it.

New tests

  • trusted proxy chain selects the nearest untrusted client address — covers a two-hop trusted proxy chain where the attacker forged the leftmost value and the real client address sits on the right; verifies the attacker cannot land in the real client's bucket.
  • trusted proxy chain with only trusted hops falls back to TCP peer — verifies that a header containing only trusted hops does not produce a spurious key.

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@server/modules/auth/tests/rate-limit.middleware.test.ts`:
- Around line 337-358: Update the proxy tests in
server/modules/auth/tests/rate-limit.middleware.test.ts at lines 337-358 to
include trusted proxy 10.0.0.1 between 198.51.100.7 and 10.0.0.2 in
X-Forwarded-For, validating trusted-hop skipping. At lines 361-389, configure
every header hop as trusted, then send the second request without
X-Forwarded-For from the same TCP peer 10.0.0.2 and assert it receives 429,
exercising TCP-peer fallback.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e0e324c-093e-48f3-b4e9-9fc64cf79036

📥 Commits

Reviewing files that changed from the base of the PR and between dae890b and 8955283.

📒 Files selected for processing (2)
  • server/modules/auth/rate-limit.middleware.ts
  • server/modules/auth/tests/rate-limit.middleware.test.ts

Comment thread server/modules/auth/tests/rate-limit.middleware.test.ts
…TCP fallback

CodeRabbit correctly noted that the two proxy-chain tests I added
previously could pass without exercising the properties they claimed:

1. The "trusted proxy chain selects the nearest untrusted client
   address" test included `198.51.100.99, 198.51.100.7` in the header,
   neither of which was a configured trusted proxy. The parser would
   pick the rightmost untrusted entry regardless of whether the
   trusted-hop skip was implemented correctly, so the test gave a
   false sense of coverage.

   Update the header to include the inner trusted proxy `10.0.0.1`
   between the forged leftmost value and the real client:
   `198.51.100.99, 10.0.0.1, 198.51.100.7`. The parser must now skip
   `10.0.0.2` (the TCP peer) and `10.0.0.1` (the configured trusted
   hop) before landing on `198.51.100.7`. A buggy implementation
   that picks the leftmost or rightmost value without skipping
   trusted hops would assign the request to the wrong bucket.

2. The "trusted proxy chain with only trusted hops falls back to TCP
   peer" test configured only `10.0.0.1` as trusted while the header
   included `10.0.0.2, 10.0.0.3, 10.0.0.1`. Walking right-to-left,
   `10.0.0.1` was skipped (trusted) but `10.0.0.3` was NOT trusted
   and would be returned, so the test was actually exercising the
   untrusted-hop case, not the TCP-peer fallback.

   Configure every header hop (`10.0.0.1`, `10.0.0.2`, `10.0.0.3`)
   as trusted. After the first call lands on the TCP peer fallback
   (`10.0.0.2`), send a second request from the same TCP peer
   *without* the `X-Forwarded-For` header and assert it receives
   `429`. The second request shares the TCP-peer bucket and proves
   the fallback path is observable end-to-end.

Co-authored-by: cgsdn <[email protected]>
@wjc2821296948

Copy link
Copy Markdown
Author

52d5cd0test(auth): tighten proxy tests to exercise trusted-hop skipping and TCP fallback

CodeRabbit correctly noted that the two proxy-chain tests I added previously could pass without exercising the properties they claimed.

Test #1 — trusted-hop skipping

The previous "trusted proxy chain selects the nearest untrusted client address" test included 198.51.100.99, 198.51.100.7 in the header, neither of which was a configured trusted proxy. The parser would pick the rightmost untrusted entry regardless of whether the trusted-hop skip was implemented correctly, so the test gave a false sense of coverage.

Update the header to include the inner trusted proxy 10.0.0.1 between the forged leftmost value and the real client: 198.51.100.99, 10.0.0.1, 198.51.100.7. The parser must now skip 10.0.0.2 (the TCP peer) and 10.0.0.1 (the configured trusted hop) before landing on 198.51.100.7. A buggy implementation that picks the leftmost or rightmost value without skipping trusted hops would assign the request to the wrong bucket.

Test #2 — TCP-peer fallback

The previous "trusted proxy chain with only trusted hops falls back to TCP peer" test configured only 10.0.0.1 as trusted while the header included 10.0.0.2, 10.0.0.3, 10.0.0.1. Walking right-to-left, 10.0.0.1 was skipped (trusted) but 10.0.0.3 was NOT trusted and would be returned, so the test was actually exercising the untrusted-hop case, not the TCP-peer fallback.

Configure every header hop (10.0.0.1, 10.0.0.2, 10.0.0.3) as trusted. After the first call lands on the TCP peer fallback (10.0.0.2), send a second request from the same TCP peer without the X-Forwarded-For header and assert it receives 429. The second request shares the TCP-peer bucket and proves the fallback path is observable end-to-end.

🤖 Generated with Claude Code

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.

1 participant