feat(auth): make rate limits configurable via env vars - #1989
Conversation
…fields Required by new AppConfig fields added in parent commit. Co-Authored-By: Claude dev-team-lead <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com>
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer]
Security review of PR #1989 — feat(auth): make rate limits configurable via env vars.
Summary
Validation logic, route wiring, and test coverage are solid. Two findings: one Medium gap in the window validation that could allow an operator to accidentally disable rate limiting, and one Low cosmetic inconsistency. No High or Critical findings. Verdict: APPROVED_WITH_SUGGESTIONS.
[MEDIUM] AUTH_RATE_LIMIT_WINDOW=0s passes validation but completely disables rate limiting
OWASP: A07 – Identification & Authentication Failures
Status: Open
Description: The regex accepts 0 as a valid numeric prefix (e.g. 0s, 0m, 0h, 0 minutes). The ms library correctly parses these to 0. @fastify/rate-limit's LocalStore checks expiry as iterationStartMs + timeWindow <= nowInMs. With timeWindow = 0, this simplifies to iterationStartMs <= nowInMs, which is mathematically always true (≤ is non-strict and time never runs backwards). As a result, every request resets the counter to 1, and the configured max is never reached.
Affected file: server/src/plugins/config.ts — the AUTH_RATE_LIMIT_WINDOW_PATTERN regex.
Proof of concept:
AUTH_RATE_LIMIT_WINDOW=0s
# → ms('0s') = 0
# → LocalStore.incr: condition (iterationStartMs + 0 <= nowInMs) is always true
# → counter resets to 1 on every call
# → rate limit with max:20 is never triggered regardless of request volume
This is the symmetric gap to the MAX validation, which correctly rejects 0 and negatives. The design intent (startup failure on bad config) is undermined if a zero-duration window silently nullifies the limit.
Remediation: After the regex check, parse the value with the ms library and assert the result is a positive number:
import ms from 'ms';
const authRateLimitWindowMs = ms(authRateLimitWindowStr);
if (authRateLimitWindowMs === undefined || authRateLimitWindowMs <= 0) {
errors.push(
`AUTH_RATE_LIMIT_WINDOW must resolve to a positive duration, got: ${authRateLimitWindowStr}`,
);
}The ms library is already a transitive dependency of @fastify/rate-limit — importing it directly adds no new package. This approach is more robust than a regex tweak because it validates the semantics, not just the syntax.
Risk if unaddressed: An operator who sets AUTH_RATE_LIMIT_WINDOW=0s (perhaps intending "no window" semantics) silently disables brute-force protection on the login endpoint with no startup warning and no obvious error. Risk is bounded to operator-level access; external attackers cannot set this.
[LOW] AUTH_RATE_LIMIT_MAX accepts non-integer decimals and silently truncates
Status: Open
Description: parseInt('1.5', 10) returns 1, which passes the > 0 check. So AUTH_RATE_LIMIT_MAX=1.5 is accepted without error and silently becomes 1. The documented contract says "positive integer."
Affected file: server/src/plugins/config.ts — the parseInt(authRateLimitMaxStr, 10) line.
Remediation: Validate the string shape before parsing:
const authRateLimitMaxStr = getValue('AUTH_RATE_LIMIT_MAX') ?? '20';
if (!/^\d+$/.test(authRateLimitMaxStr)) {
errors.push(`AUTH_RATE_LIMIT_MAX must be a positive integer, got: ${authRateLimitMaxStr}`);
} else {
const authRateLimitMax = parseInt(authRateLimitMaxStr, 10);
if (authRateLimitMax <= 0) {
errors.push(`AUTH_RATE_LIMIT_MAX must be a positive integer, got: ${authRateLimitMaxStr}`);
}
}This also normalizes the leading-whitespace behavior: parseInt silently accepts ' 20' (passing), whereas the WINDOW regex correctly rejects ' 1h' (failing ^\d+ anchoring). Using /^\d+$/ first makes both fields reject whitespace-padded inputs consistently.
[INFORMATIONAL] X-RateLimit-{Limit,Remaining} headers reveal configured max to clients
Status: Accepted Risk (by design — integration test asserts header presence)
@fastify/rate-limit sends X-RateLimit-Limit and X-RateLimit-Remaining on every login response. An attacker can read the configured authRateLimitMax value directly from any login response. This is standard practice; clients use it for graceful backoff. An attacker can determine the limit empirically anyway. No action required. If a future requirement calls for hiding the configured limit, addHeaders: { 'x-ratelimit-limit': false } is available.
[INFORMATIONAL] Startup log includes rate limit configuration
Status: Accepted Risk
authRateLimitMax and authRateLimitWindow appear in the Configuration loaded info log. Appropriate — these are non-secret operational parameters consistent with all other non-sensitive config fields already logged. No action needed.
Checklist
- No SQL/command/XSS injection vectors in new code
- Authentication/authorization enforced — no new unprotected endpoints
- No sensitive data exposed in logs, errors, or client responses
- Input validated at startup via
loadConfig()with fail-fast on bad values - MAX=0 and negative values correctly rejected
- WINDOW=0 (zero duration) not rejected — Medium gap (see above)
- No new dependencies introduced
- No hardcoded credentials or secrets
- Setup route hardcoded limit (max:5 / 15 min) is appropriate — route returns 403 unconditionally post-setup, tuning has no operational value (AC6 rationale is sound)
- CORS configuration unchanged
- Error responses do not leak internal details
- Test coverage: unit tests for valid/invalid MAX and WINDOW, integration test confirming configured max triggers 429 at the correct threshold, defaults verified
The zero-window issue is the only non-informational finding and requires operator-level access to trigger. Non-blocking for merge; recommend filing a follow-up hardening issue.
|
[product-architect] Verdict: CHANGES_REQUIREDOne high-severity finding: a value that passes HIGH —
|
|
[product-owner] Verdict: CHANGES_REQUIREDReviewed against the seven acceptance criteria on #1970. Six are met or substantially met; AC2 has a concrete, reproducible violation of its own stated guarantee, and it is the security-relevant half of the story, so this is blocking rather than a follow-up. AC-by-AC
B1 (BLOCKING, High) —
|
- @lukeed/ms zero-duration hole in @fastify/rate-limit timeWindow validation - loadConfig parseInt accepts trailing garbage repo-wide - env vars are documented in four places, not one Co-Authored-By: Claude product-architect <noreply@anthropic.com>
…e, update wiki - AUTH_RATE_LIMIT_WINDOW now rejects 0s/0m/0h (zero magnitude causes runtime 500 via @lukeed/ms) - Regex uses space-only separator (* not \s*) matching @lukeed/ms parsing rules - Route-level x-ratelimit-limit header assertions prove config reaches the route - Wiki Architecture.md and API-Contract.md updated with new env var documentation Co-Authored-By: Claude dev-team-lead <noreply@anthropic.com> Co-Authored-By: Claude backend-developer <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com> Co-Authored-By: Claude product-architect <noreply@anthropic.com>
|
[product-architect] Re-review of Blocking finding: zero-magnitude window → 500 crash — RESOLVEDVerified in 1. Separator narrowed 2. Positive-magnitude guard. Exhaustive accept-set cross-checkRather than trust the reasoning, I generated every combination of the 22 accepted units (both cases) × 8 magnitudes × 3 separator widths and asserted that everything passing config's full gate also parses to a positive finite millisecond count: Zero remaining divergence in the dangerous direction. The only surviving divergence is one-directional and fail-closed: config is strictly narrower than Other verifications
Non-blocking follow-ups (do not block this merge)
VERDICT: APPROVED |
…nique - Regex-vs-library divergence: only the wider direction blocks; narrower is fail-closed - Brute-force accept-set cross-check beats inspection; import dep via relative dist path - Correct the jest --modulePathIgnorePatterns note: it self-excludes when run from a worktree - Env-var doc locations: 3-of-4 plus a flagged docs-writer follow-up is the passing bar Co-Authored-By: Claude product-architect <noreply@anthropic.com>
|
[product-owner] Verdict: APPROVED — with one MUST FIX before mergeRound 2 review of B1 is resolved. M1 is not — the added assertions prove B1 — RESOLVED: zero-magnitude window rejected at startup
} else if (parseFloat(authRateLimitWindowStr) <= 0) {
errors.push(`AUTH_RATE_LIMIT_WINDOW must have a positive duration (zero magnitude is not allowed), got: ${authRateLimitWindowStr}`);
}Verified:
Worth recording for the file: my round-1 mechanism was wrong and AC2 and AC7 are now met: there is no accepted value that removes the login limit. M1 — STILL OPEN (MUST FIX before merge): no assertion observes the effective window at the route
The two new assertions are on
That is exactly the regression AC1 ("observes the effective limit") and AC4 ("asserted, so the default cannot drift silently") were written to catch, and it is the half of the pair that still is not caught. The vehicle is already there. // Window reaches the route too: 15 minutes → 900s, not the global 1-minute fallback (60)
expect(response.headers['x-ratelimit-reset']).toBe('900');Optionally also a non-default case ( To be unambiguous about the gate: merge is a code gate, Done is an acceptance gate. This PR is approved to merge on the strength of B1, but AC1 and AC4 both contain an explicit assertion clause and the window half of each is currently satisfied only at the config layer. If this merges without the assertion, #1970 does not go to Done and I will reopen it rather than file a follow-up. AC status
Rulings on the other reviewers' open findings
|
…ry index - Correct the AUTH_RATE_LIMIT_WINDOW=0s mechanism (500 via mergeParams, not silent disable) and record that x-ratelimit-limit proves max, not timeWindow - Move #1970 detail into auth-rate-limits-1970.md; file #1991/#1992 rationale - Compact MEMORY.md from 20.8KB to 8.6KB by moving Bank Report Wizard narrative into its topic file, keeping one-line hooks in the index Co-Authored-By: Claude product-owner <noreply@anthropic.com>
…es route (AC4) Co-Authored-By: Claude dev-team-lead <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester <noreply@anthropic.com>
|
[product-owner] Round 3 (final) — M1 resolved. Verdict: APPROVED
expect(response.headers['x-ratelimit-reset']).toBe('900');What I verified
Both halves of the route's rate-limit config are now observed at the route: Done gateMy round-2 ruling — merge is a code gate, Done is an acceptance gate — is now satisfied on both sides: #1970 is eligible for Done on merge, no reopen and no substitute follow-up needed. Follow-ups #1990 (docs-site copy, must cross-reference No further findings. Approving; CI gates remain the merge precondition. |
|
🎉 This PR is included in version 2.14.0-beta.1 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
… gate (#1992) - Remove the `OIDC_REDIRECT_URI` row from the auth env-var tables in `wiki/Architecture.md` and `wiki/API-Contract.md`. The server never reads it — the full 32-variable `getValue(...)` read-set in `config.ts` contains no such name — so an operator who set it got no effect. - Correct the "OIDC is enabled when all four OIDC variables are set" claim on both pages. The gate is three (`config.ts:142`), and an operator who correctly set only those three had reason to believe they had misconfigured something. - Replace the removed row with `EXTERNAL_URL`, now the load-bearing variable for the callback URL, and document how the URL is actually derived (`oidc.ts:45`) — including that **both** halves of the request-host fallback are gated on `TRUST_PROXY`, so a correct scheme alone does not make the fallback safe. - Close the `2026-08-04` Deviation Log entry that had flagged this as an unresolved follow-up from PR #1989, correcting its own wrong claim that `CLAUDE.md` shared the discrepancy. Add a Deviation Log convention to both pages: correct forward, never rewrite a Deviation cell — the log records what we believed and how we got it wrong, which is the only thing that distinguishes it from a changelog. No production code changes. Refs #1992 Co-Authored-By: Claude product-architect <noreply@anthropic.com> Co-Authored-By: Claude product-owner <noreply@anthropic.com>
|
🎉 This PR is included in version 2.14.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
AUTH_RATE_LIMIT_MAXandAUTH_RATE_LIMIT_WINDOWenv vars to configure the login endpoint rate limit (defaults: 20 requests / 15 minutes)Fixes #1970
Test plan
Co-Authored-By: Claude dev-team-lead noreply@anthropic.com
Co-Authored-By: Claude backend-developer noreply@anthropic.com
Co-Authored-By: Claude qa-integration-tester noreply@anthropic.com