Skip to content

feat: add IP-based rate limiting for login and reset-password - #3329

Merged
jbair06 merged 16 commits into
mainfrom
ip-rate-limit-reset-password
Aug 27, 2026
Merged

feat: add IP-based rate limiting for login and reset-password#3329
jbair06 merged 16 commits into
mainfrom
ip-rate-limit-reset-password

Conversation

@jbair06

@jbair06 jbair06 commented Aug 24, 2026

Copy link
Copy Markdown
Member

Adds IP-keyed throttler guards to POST /auth/login and POST /auth/reset-password, plus a unique-email-per-IP guard for password reset backed by a Redis set.

Fixes #3328

See the issue for full details on limits and env vars.

jbair06 and others added 3 commits August 21, 2026 15:23
Fixes #3328

Signed-off-by: John Bair <john.bair@swirldslabs.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: John Bair <john.bair@swirldslabs.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes #3328

Signed-off-by: John Bair <john.bair@swirldslabs.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jbair06
jbair06 requested a review from a team as a code owner August 24, 2026 16:22
@jbair06 jbair06 linked an issue Aug 24, 2026 that may be closed by this pull request
@jbair06
jbair06 requested a review from svienot August 24, 2026 16:22
@swirlds-automation

swirlds-automation commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@jbair06 jbair06 self-assigned this Aug 24, 2026
@jbair06 jbair06 added this to the v0.38.0 milestone Aug 24, 2026
@jbair06
jbair06 requested a lite review from Copilot August 24, 2026 16:24
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.98%. Comparing base (62703ec) to head (1e94941).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #3329   +/-   ##
=======================================
  Coverage   99.98%   99.98%           
=======================================
  Files         209      212    +3     
  Lines        6601     6650   +49     
  Branches     1228     1205   -23     
=======================================
+ Hits         6600     6649   +49     
  Misses          1        1           
Files with missing lines Coverage Δ
back-end/apps/api/src/auth/auth.controller.ts 100.00% <ø> (ø)
...-end/apps/api/src/guards/frontend-version.guard.ts 100.00% <100.00%> (ø)
...nd/apps/api/src/guards/ip-login-throttler.guard.ts 100.00% <100.00%> (ø)
...pi/src/guards/ip-reset-password-throttler.guard.ts 100.00% <100.00%> (ø)
...src/guards/ip-reset-password-unique-email.guard.ts 100.00% <100.00%> (ø)
back-end/apps/api/src/guards/ip-throttler.guard.ts 100.00% <100.00%> (ø)

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI 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.

Pull request overview

Adds IP-keyed rate limiting and an IP→unique-email constraint to the authentication flows to mitigate credential stuffing and password-reset spam (per issue #3328), using a shared client-IP extraction utility and Redis-backed throttling.

Changes:

  • Introduces extractClientIp() and applies it to the global IpThrottlerGuard.
  • Adds IpLoginThrottlerGuard and IpResetPasswordThrottlerGuard and wires them into /auth/login and /auth/reset-password.
  • Adds IpResetPasswordUniqueEmailGuard backed by a Redis set + TTL window, and documents the Redis 7.0+ prerequisite.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
back-end/README.md Documents Redis 7.0+ requirement for EXPIRE ... NX usage in the new reset-password unique-email guard.
back-end/libs/common/src/utils/index.ts Exposes the new extractClientIp utility via the common utils barrel export.
back-end/libs/common/src/utils/extractClientIp.ts Adds shared client-IP resolution (CF-Connecting-IP → X-Forwarded-For → req.ip).
back-end/apps/api/src/guards/ip-throttler.guard.ts Updates global throttling tracker to use extractClientIp.
back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts Adds Redis-set-backed “max unique emails per IP per window” guard for reset-password.
back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts Adds IP-based throttling limits for reset-password endpoint.
back-end/apps/api/src/guards/ip-login-throttler.guard.ts Adds IP-based throttling limits for login endpoint.
back-end/apps/api/src/guards/index.ts Exports new IP throttler/unique-email guards.
back-end/apps/api/src/auth/auth.controller.ts Applies new guards to /auth/login and /auth/reset-password.
Suppressed comments (5)

back-end/libs/common/src/utils/extractClientIp.ts:19

  • req.headers[...] values can be string[] (Node/Express typings allow this). Today, if cf-connecting-ip or x-forwarded-for is provided as an array, this helper ignores it and falls back to req.ip, which can change rate-limit tracking keys unexpectedly.
  const cf = req.headers?.['cf-connecting-ip'];
  if (cf && typeof cf === 'string' && cf.trim()) {
    return cf.trim();
  }

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts:27

  • The unique-email limit can be bypassed by varying email casing/whitespace, and req.body.email may not be a string (guards run before DTO validation pipes). Normalize + validate before writing to Redis so the set truly tracks unique emails per IP.
    const email: string = req.body?.email;
    if (!email) return true; // let EmailThrottlerGuard handle the missing email case

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts:44

  • When concurrent requests exceed the unique-email limit, this guard throws but leaves the extra email(s) in the Redis set. That can make the set size permanently exceed the configured limit for the rest of the window, causing over-blocking beyond the intended "max N unique emails" constraint.
    await this.redis.sadd(key, email);
    await this.redis.expire(key, TEN_MINUTES_SECONDS, 'NX');

    // Re-check after adding to handle concurrent requests
    const countAfter = await this.redis.scard(key);

back-end/apps/api/src/auth/auth.controller.ts:139

  • IpResetPasswordUniqueEmailGuard currently runs before the IP and email throttlers, so it can consume unique-email slots even for requests that would be rejected by throttling (or by missing/invalid emails). Reordering keeps the unique-email set accurate and reduces unnecessary Redis work.
  @UseGuards(IpResetPasswordUniqueEmailGuard, IpResetPasswordThrottlerGuard, EmailThrottlerGuard)

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts:18

  • This guard adds new, security-relevant rate-limiting behavior (Redis set semantics, TTL handling, and concurrency edge cases) but has no corresponding unit tests. The guards directory already has Jest coverage for similar components (e.g., email-throttler.guard.spec.ts, ip-throttler.guard.spec.ts).
  async canActivate(context: ExecutionContext): Promise<boolean> {

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread back-end/libs/common/src/utils/extractClientIp.ts Outdated
Comment thread back-end/apps/api/src/auth/auth.controller.ts Outdated
Comment thread back-end/README.md Outdated
Comment thread back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts Outdated
Signed-off-by: John Bair <john.bair@swirldslabs.com>
Covers CF-Connecting-IP precedence, X-Forwarded-For multi-value
parsing, and header string[] fallback cases per Copilot review.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
…utes

Guards short-circuit in declaration order, so placing EmailThrottlerGuard
last let failed attempts skip the per-email throttle. Moved it first on
login and reset-password so it always counts the attempt.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
Match the named `{ Redis }` import used elsewhere in the repo, and
clarify that Redis <7.0 errors on EXPIRE ... NX rather than silently
skipping it.

Signed-off-by: John Bair <john.bair@swirldslabs.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (4)

Previously missed (3) — in code that hasn't changed since the last review.

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts:16

  • RESET_IP_UNIQUE_EMAIL_LIMIT is parsed with Number(...) without validating the result. If the env var is misconfigured (e.g., empty or non-numeric), this.limit becomes NaN and the guard will never trip, effectively disabling the unique-email protection.
  constructor(@Inject(ConfigService) configService: ConfigService) {
    this.redis = new Redis(configService.getOrThrow('REDIS_URL'));
    this.limit = Number(configService.get('RESET_IP_UNIQUE_EMAIL_LIMIT', 3));
  }

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts:18

  • IpResetPasswordUniqueEmailGuard introduces new Redis-backed security logic (unique-email tracking, TTL behavior, and limit enforcement) but has no unit tests, while other throttler guards in this folder do (e.g., email-throttler.guard.spec.ts, ip-throttler.guard.spec.ts). Adding tests would help prevent regressions around race conditions and TTL semantics.
  async canActivate(context: ExecutionContext): Promise<boolean> {

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts:47

  • The current SCARD→SADD→SCARD flow is not safe under concurrency: multiple parallel requests can all pass countBefore < limit, add different emails, and then fail after the add. Those rejected requests still permanently grow the Redis set for the window, causing an IP to be blocked longer than intended (self-inflicted DoS) and making behavior dependent on race timing.
    // Check current unique email count before adding
    const countBefore = await this.redis.scard(key);
    if (countBefore >= this.limit) {
      throw new HttpException('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
    }

back-end/libs/common/src/utils/extractClientIp.ts:19

  • extractClientIp ignores valid string[] header shapes (possible for IncomingHttpHeaders) and also re-parses X-Forwarded-For even when Express has already computed a trusted req.ips/req.ip value. This can lead to using the wrong tracker IP (e.g., proxy IP or a spoofed header) and undermines the reliability of rate limiting.
  const cf = req.headers?.['cf-connecting-ip'];
  if (cf && typeof cf === 'string' && cf.trim()) {
    return cf.trim();
  }

2 attempts per 10s was easy for a legit user to trip since the guard
throttles by shared IP, not per account. Raise defaults to 5/10s and
20/min, and document the new IP throttle env vars in example.env.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
Only mark the email/password inputs invalid on a real 401, so a
rate-limited attempt doesn't look like a bad-credentials error.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
@jbair06
jbair06 requested a lite review from Copilot August 24, 2026 17:35

Copilot AI 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.

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

Covers IpLoginThrottlerGuard and IpResetPasswordThrottlerGuard's
getTracker IP resolution, and IpResetPasswordUniqueEmailGuard's
missing-IP, missing-email, under-limit, at-limit, and race-condition
paths. Brings apps/api/src/guards to 100% coverage.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
Shared E2E logs in many users rapidly from one CI runner IP,
tripping LOGIN_IP_*/RESET_IP_* limits that weren't in the
existing throttle-loosening block.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
@jbair06
jbair06 requested review from a team as code owners August 24, 2026 19:17
@jbair06
jbair06 requested a review from a team as a code owner August 24, 2026 19:17
Comment thread back-end/libs/common/src/utils/extractClientIp.ts Outdated
Comment thread back-end/libs/common/src/utils/extractClientIp.ts Outdated
Comment thread back-end/libs/common/src/utils/extractClientIp.ts Outdated
Comment thread back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts Outdated
Comment thread back-end/libs/common/src/utils/extractClientIp.ts Outdated
Comment thread back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts Outdated
Swaps the spoofable extractClientIp()/XFF-CIDR approach for an
IpResolverService that only trusts CF-Connecting-IP, plus fixes for the
PR review's reset-password guard gaps (limit validation, email
normalization, full-lockout behavior).

Signed-off-by: John Bair <john.bair@swirldslabs.com>
Comment thread back-end/libs/common/src/ip-resolution/normalize-ip.ts Outdated
Comment thread back-end/libs/common/src/ip-resolution/internal-address.ts Outdated
…nd-rolled logic

Replaces the regex-based normalizeIp/isInternalAddress helpers with
ipaddr.js (already a transitive dep via proxy-addr, now direct) for
validation, RFC 5952 canonicalization, and range classification -- and
folds both into private IpResolverService methods since neither had any
other consumer.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
Removes a redundant double validity-check on the header path, and makes
an invalid req.ip fall back to the fixed '0.0.0.0' bucket instead of
passing an unvalidated string through as the rate-limit key.

Signed-off-by: John Bair <john.bair@swirldslabs.com>
…assword

Signed-off-by: John Bair <john.bair@swirldslabs.com>

# Conflicts:
#	back-end/package.json
Comment thread back-end/libs/common/src/ip-resolution/ip-resolver.service.ts Outdated
Comment thread back-end/libs/common/src/ip-resolution/ip-resolver.service.ts Outdated
@steven-sheehy steven-sheehy added Feature Enhancement Enhancing an existing feature driven by business requirements. Typically backwards compatible. Backend labels Aug 27, 2026
Merges toSafeFallback and isInternal into one resolveFallback method
so the fallback IP is validated/parsed a single time instead of once
for the canonical string and again for the range classification.

Signed-off-by: John Bair <john.bair@swirldslabs.com>

@steven-sheehy steven-sheehy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@jbair06
jbair06 merged commit aaf2c19 into main Aug 27, 2026
28 checks passed
@jbair06
jbair06 deleted the ip-rate-limit-reset-password branch August 27, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Feature Enhancement Enhancing an existing feature driven by business requirements. Typically backwards compatible. security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add IP-based rate limiting to login and password reset endpoints

4 participants