Skip to content

feat(rate-limit): env-configurable limits + trusted-hop client keying - #237

Closed
henrique221 wants to merge 4 commits into
mainfrom
feat/rate-limit-config
Closed

feat(rate-limit): env-configurable limits + trusted-hop client keying#237
henrique221 wants to merge 4 commits into
mainfrom
feat/rate-limit-config

Conversation

@henrique221

@henrique221 henrique221 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closes #210.

Summary

Implements the two scale-out follow-ups from Kasey's review of #209: the rate limiter's hard-coded constants are now env-configurable (current values as defaults), and the trusted-proxy assumption in clientKey is a config knob instead of a baked-in "last x-forwarded-for entry" rule.

Default behavior is unchanged — with no RATE_LIMIT_* vars set, the limiter still enforces 20 req/min per client IP with a 10k-bucket cap, keyed on the proxy-appended last XFF entry.

Env vars (all optional)

Var Rule Default
RATE_LIMIT_WINDOW_MS int > 0 60000
RATE_LIMIT_MAX int > 0 20
RATE_LIMIT_MAX_BUCKETS int > 0 10000
RATE_LIMIT_TRUSTED_HOPS int ≥ 0 1

Parsed via a new blank-safe envInt() helper (same contract as envBool): a bare RATE_LIMIT_MAX= line — exactly how .env.example documents optional vars — counts as unset (default applies) instead of being coerced to 0, which would otherwise fail boot for the positive vars and silently flip TRUSTED_HOPS to socket keying. Invalid values fail boot loudly through the existing EnvSchema.safeParse → exit path.

Trusted-hop client keying

clientKey now implements standard proxy-addr semantics driven by RATE_LIMIT_TRUSTED_HOPS:

candidates = [socket remote address, ...XFF entries right-to-left]
key = stripPort(candidates[min(trustedHops, candidates.length - 1)])
  • 1 (default): last XFF entry — exactly today's Azure App Service behavior (App Service appends the real client IP; leading entries stay client-controlled/spoofable).
  • 2: second-from-last, for an extra appending LB layer in front.
  • 0: no trusted proxy — XFF ignored entirely, key on the socket address (via @hono/node-server's getConnInfo, try/caught so the middleware never throws at request time).
  • XFF absent: socket address; no socket reachable: shared 'unknown' bucket (previous fallback).

One deliberate improvement: local dev without a proxy now gets per-client socket buckets instead of one shared 'unknown' bucket.

rateLimit()'s options are all optional now (unset fields resolve from env), the bulk-texts route uses the zero-arg form, and its OpenAPI 429 copy says "default 20 requests per minute" so operator overrides don't make the spec lie. The TODO(#210) comment is gone.

Testing

  • 18 new tests (9 envInt unit cases; 9 middleware cases covering the full hops matrix: 0/1/2, short chains, missing XFF, missing socket, env-default resolution, custom maxBuckets eviction). All 7 pre-existing limiter tests pass byte-untouched — the hops=1 default is a strict generalization of the old rule.
  • Full suite 186/186, lint/format/typecheck clean.
  • Boot smokes: RATE_LIMIT_TRUSTED_HOPS=-1 fails boot naming the field; RATE_LIMIT_MAX=5 override is picked up.

Non-blocking follow-up (from review)

env.test.ts exercises envInt through ad-hoc schemas; asserting blank⇒default against the concrete RATE_LIMIT_* schema fields would need EnvSchema exported — worth a tiny follow-up if we want the wiring itself pinned.

Summary by CodeRabbit

  • New Features

    • Added configurable rate limiting for anonymous endpoints, including request limits, time windows, client bucket capacity, and proxy trust settings.
    • Improved client identification using proxy headers and connection addresses.
    • Added bounded storage with automatic eviction of older client buckets.
  • Bug Fixes

    • Blank rate-limit configuration values now correctly use documented defaults.
    • Bulk Bible text requests now use the shared default rate-limit configuration.
  • Documentation

    • Documented new rate-limit environment settings and their defaults.

@henrique221 henrique221 added the enhancement New feature or request label Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Rate limiting now supports environment-configured defaults, trusted proxy hop selection, socket-address fallback, and configurable bucket eviction. The bulk texts route uses these defaults, with added validation and middleware tests. The example environment and gitignore files were updated.

Changes

Rate limiter configuration and behavior

Layer / File(s) Summary
Rate limiter environment contracts
src/env.ts, src/env.test.ts
Adds blank-safe integer parsing and environment variables for rate limiter window, request limit, bucket capacity, and trusted proxy hops, with validation tests.
Client keying and bucket eviction
src/middlewares/rate-limit.ts, src/middlewares/rate-limit.test.ts
Resolves configurable limits, derives client identity from trusted proxy hops or socket addresses, and bounds bucket storage with configurable eviction.
Bulk texts route configuration
src/domains/bibles/bible-texts/bible-texts.route.ts, .env.example
The bulk texts route uses default rate limiter configuration, and the example environment documents the related settings and default 429 wording.

Repository scratch workspace hygiene

Layer / File(s) Summary
Scratch workspace ignore rule
.gitignore
Ignores the .superpowers/ scratch workspace.

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

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant rateLimit
  participant clientKey
  participant BucketStore
  Request->>rateLimit: submit request
  rateLimit->>clientKey: resolve client identity
  clientKey-->>rateLimit: proxy or socket address
  rateLimit->>BucketStore: read or create bounded bucket
  BucketStore-->>rateLimit: allowance or rejection
  rateLimit-->>Request: continue or return 429
Loading

Suggested reviewers: joel-joseph-george

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning .gitignore's .superpowers/ entry is unrelated to the rate-limiter objectives and looks like an out-of-scope addition. Remove the .gitignore-only change or split it into a separate housekeeping PR.
✅ 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: configurable rate-limit settings and trusted-hop client keying.
Linked Issues check ✅ Passed The PR implements env-configurable limits, trusted-hop client keying, and reusable defaulted limiter options as requested in #210.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rate-limit-config

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
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 `@src/middlewares/rate-limit.ts`:
- Around line 46-61: Update clientKey so an x-forwarded-for chain shorter than
trustedHops falls back to socketAddress(c) ?? 'unknown' rather than selecting
the leftmost forwarded entry; preserve the existing trusted-hop candidate
selection when the chain is long enough.
🪄 Autofix (Beta)

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

Review profile: CHILL

Plan: Pro Plus

Run ID: ab65374b-6c23-4044-8093-be5a8768a4ef

📥 Commits

Reviewing files that changed from the base of the PR and between 3fd1027 and 46d486c.

📒 Files selected for processing (7)
  • .env.example
  • .gitignore
  • src/domains/bibles/bible-texts/bible-texts.route.ts
  • src/env.test.ts
  • src/env.ts
  • src/middlewares/rate-limit.test.ts
  • src/middlewares/rate-limit.ts

Comment on lines +46 to 61
// Resolve the client key from the deployment's trusted-proxy topology
// (standard proxy-addr semantics): walk [socket, ...x-forwarded-for entries
// right-to-left] and take the entry trustedHops steps out — each trusted
// proxy APPENDS exactly one entry (Azure App Service appends the real client
// IP as ip[:port]), so that position is the closest address a client cannot
// spoof, while leading entries are client-controlled. A chain shorter than
// trustedHops degrades to its furthest (leftmost) available entry.
function clientKey(c: Context<AppBindings>, trustedHops: number): string {
const forwarded = c.req.header('x-forwarded-for') ?? '';
const entries = forwarded
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
const candidates = [socketAddress(c) ?? 'unknown', ...entries.reverse()];
return stripPort(candidates[Math.min(trustedHops, candidates.length - 1)]);
}

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)rate-limit(\.test)?\.ts$|src/middlewares/rate-limit\.ts|package\.json|vitest|node_modules' || true

echo "== target file outline/size =="
wc -l src/middlewares/rate-limit.ts src/middlewares/rate-limit.test.ts 2>/dev/null || true
ast-grep outline src/middlewares/rate-limit.ts --view expanded 2>/dev/null || true

echo "== rate-limit.ts relevant =="
sed -n '1,130p' src/middlewares/rate-limit.ts

echo "== tests around claimed lines =="
sed -n '200,270p' src/middlewares/rate-limit.test.ts

echo "== searched issues/topo constants =="
rg -n "trustedHops|x-forwarded-f|clientKey|degrades to|socketAddress|stripPort" src -S || true

Repository: eten-tech-foundation/fluent-api

Length of output: 15421


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== environment/default trustedHops definitions =="
sed -n '1,130p' src/env.ts

echo "== deterministic clientKey behavior probe =="
python3 - <<'PY'
def strip_port(entry):
    if entry.startswith('['):
        i = entry.index(']')
        return entry[1:i] if i != -1 else entry
    parts = entry.split(':')
    return parts[0] if len(parts) == 2 else entry

def client_key(socket_address, headers, trusted_hops):
    forwarded = headers.get('x-forwarded-for', '')
    entries = [e.strip() for e in forwarded.split(',') if e.strip()]
    candidates = [socket_address if socket_address else 'unknown', *(e for e in reversed(entries))]
    return strip_port(candidates[min(trusted_hops, len(candidates) - 1)])

for (hops, xff) in [
    (0, '1.1.1.1'),
    (1, '1.1.1.1'),
    (2, 'spoof, 1.1.1.1, 10.0.0.7'),
    (3, '1.1.1.1'),
    (3, ''),
]:
    print({
        "trustedHops": hops,
        "x_forwarded_for": xff or None,
        "9_9_9_9": client_key("9.9.9.9", {'x-forwarded-for': xff}, hops),
        "socket_missing": client_key(None, {'x-forwarded-for': xff}, hops),
    })
PY

Repository: eten-tech-foundation/fluent-api

Length of output: 7903


Fall back to the socket address when x-forwarded-for underflows trustedHops.

clientKey currently selects candidates[Math.min(trustedHops, candidates.length - 1)], so with trustedHops: 3 and a single header entry, both different sockets key on the attacker-supplied header value and share one bucket (trustedHops=3, x-forwarded-for='1.1.1.1' from 10.0.0.7 and 10.0.0.8). When the chain is shorter than the configured trusted hop count, use socketAddress(c) ?? 'unknown' instead of the leftmost header entry.

🤖 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/middlewares/rate-limit.ts` around lines 46 - 61, Update clientKey so an
x-forwarded-for chain shorter than trustedHops falls back to socketAddress(c) ??
'unknown' rather than selecting the leftmost forwarded entry; preserve the
existing trusted-hop candidate selection when the chain is long enough.

@kaseywright kaseywright 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.

Really good implementation. Only one issue blocking the merge.

.map((entry) => entry.trim())
.filter(Boolean);
const candidates = [socketAddress(c) ?? 'unknown', ...entries.reverse()];
return stripPort(candidates[Math.min(trustedHops, candidates.length - 1)]);

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.

It appears that this issue flagged by CodeRabbit is accurate.
clientKey degrades to an attacker-controlled value when trustedHops exceeds the actual chain length (src/middlewares/rate-limit.ts:53-61).

const candidates = [socketAddress(c) ?? 'unknown', ...entries.reverse()];
return stripPort(candidates[Math.min(trustedHops, candidates.length - 1)]);

When the XFF chain is shorter than trustedHops (misconfiguration, or fewer proxies than expected in front of a given instance), Math.min clamps to the last index in candidates — which is the leftmost, client-supplied XFF entry, not the socket address. The code comment even documents this as intentional ("degrades to its furthest (leftmost) available entry"), and rate-limit.test.ts's "degrades to the leftmost entry when hops exceed the chain length" test locks it in.

This matters because it's a rate-limit bypass, not just a mis-attribution: if trustedHops is ever configured higher than the real proxy count (easy to get wrong when the topology changes), an attacker can rotate the leftmost x-forwarded-for value on every request to get a fresh bucket every time, defeating the limiter entirely. CodeRabbit's suggested fix is correct — fall back to socketAddress(c) ?? 'unknown' when the chain underflows trustedHops, rather than to the leftmost header entry:

const idx = trustedHops < entries.length ? entries.length - trustedHops : -1;
return stripPort(idx >= 0 ? entries[idx] : (socketAddress(c) ?? 'unknown'));

This is the one thing I'd block on — everything else here is solid.

@henrique221

Copy link
Copy Markdown
Contributor Author

Superseded by #255, which is the same work rebased onto current main with the review feedback applied. Closing to keep the queue clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rate limiter: env-configurable limits + configurable trusted-proxy assumption (scale-out follow-up)

2 participants