feat(bible-texts): rate-limit the anonymous bulk-texts endpoint#209
Conversation
Reviewer decision (2026-06-26): anonymous access to bible-texts/books
reads is intentional — the bulk endpoint supports a mobile sync feature.
Per the agreed plan, keep it unauthenticated but add an abuse guard:
- New rateLimit middleware (src/middlewares/rate-limit.ts): minimal
fixed-window, per-client-IP (x-forwarded-for first hop), in-memory per
process. Returns 429 { message } with Retry-After, consistent with the
global error envelope. better-auth already rate-limits /api/auth/*;
this covers anonymous API routes.
- Applied to POST /bibles/{bibleId}/bulk-texts at 20 req/min per client
(a full mobile sync needs 1-2 requests); 429 documented in the OpenAPI
responses and the description now states the intentional-anonymous
decision.
- Unit tests for the middleware (under/over limit, window reset, per-IP
isolation, multi-hop XFF).
- Proposal docs updated: bulk-texts decision recorded, Azure Blob decision
recorded for the async pipeline (#196), ticket statuses refreshed.
Closes #199.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds a fixed-window in-memory rate limiter for anonymous bulk bible-texts requests, wires it into the route with 429/OpenAPI updates, and refreshes proposal docs to record the anonymous-read and export-storage decisions. ChangesRate limiting implementation and rollout
Estimated code review effort: 2 (Simple) | ~15 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant BulkTextsRoute
participant rateLimit
participant Logger
Client->>BulkTextsRoute: POST /bibles/{bibleId}/bulk-texts
BulkTextsRoute->>rateLimit: middleware check
alt request under limit
rateLimit->>BulkTextsRoute: next()
BulkTextsRoute->>Client: 200 response
else request over limit
rateLimit->>Logger: warn limit exceeded
rateLimit->>Client: 429 Too Many Requests + Retry-After
end
Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Pull request overview
Adds a lightweight, reusable rate-limiting middleware to protect an intentionally unauthenticated “bulk texts” endpoint from scraping/abuse, and records the related security/export hardening decisions in proposal docs.
Changes:
- Introduces an in-memory fixed-window
rateLimitmiddleware keyed by client IP. - Applies rate limiting (20 req/min) to
POST /bibles/{bibleId}/bulk-textsand documents the429response in OpenAPI. - Updates security/export hardening proposal docs to reflect reviewer decisions and ticket statuses.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/middlewares/rate-limit.ts | New in-memory fixed-window rate limiting middleware for anonymous routes. |
| src/middlewares/rate-limit.test.ts | Unit tests covering limit enforcement, reset behavior, and XFF parsing. |
| src/domains/bibles/bible-texts/bible-texts.route.ts | Applies the limiter to the bulk-texts endpoint and documents 429 in OpenAPI. |
| docs/proposals/security-and-export-hardening/README.md | Updates ticket status table with recent decisions/merges. |
| docs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.md | Records decision to use Azure Blob + signed URLs for async exports. |
| docs/proposals/security-and-export-hardening/01-auth-open-domains.md | Records decision to keep certain reads anonymous and rate-limit bulk-texts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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 `@docs/proposals/security-and-export-hardening/01-auth-open-domains.md`:
- Around line 53-59: Clarify that the bulk POST /bibles/{bibleId}/bulk-texts
limiter in rate-limit.ts is instance-local, not a global cluster-wide cap.
Update the proposal text to say the fixed-window guard in
src/middlewares/rate-limit.ts is per-process/per-replica and only enforces
limits within a single app instance. Keep the “intentionally anonymous” note,
but avoid implying a distributed 20 rpm limit unless a shared backend is added.
In `@src/domains/bibles/bible-texts/bible-texts.route.ts`:
- Around line 121-124: The 429 response in the OpenAPI contract is incomplete:
the rate limit behavior from rateLimit() includes a Retry-After header and is
keyed by client IP, not a generic client. Update the 429 response definition in
bible-texts.route.ts to document “per client IP” and add the Retry-After
response header alongside the JSON body so the published contract matches the
middleware behavior.
In `@src/middlewares/rate-limit.ts`:
- Around line 22-29: The clientKey helper is using request-supplied
X-Forwarded-For and X-Real-IP values, which can be spoofed to bypass the rate
limiter and inflate the bucket map. Update clientKey in
src/middlewares/rate-limit.ts to derive the key only from a trusted proxy
boundary or the framework’s trusted remote address source, and avoid falling
back to untrusted headers for client identity.
🪄 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: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: ceb2daf8-72e6-4901-a744-20e413568d6c
📒 Files selected for processing (6)
docs/proposals/security-and-export-hardening/01-auth-open-domains.mddocs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.mddocs/proposals/security-and-export-hardening/README.mdsrc/domains/bibles/bible-texts/bible-texts.route.tssrc/middlewares/rate-limit.test.tssrc/middlewares/rate-limit.ts
… 429 contract Azure App Service appends the real client IP to any caller-supplied x-forwarded-for, so only the last entry is trusted. Keying on the first entry let clients rotate the leading value to bypass the limiter and grow the bucket map. Now key on the proxy-appended tail (with port stripping) and drop the client-suppliable x-real-ip fallback entirely. Also add a hard MAX_BUCKETS cap (10k) with expired-sweep + oldest-first eviction at insert time to bound memory, replacing the per-request post-next sweep, and publish the Retry-After header plus per-client-IP wording in the 429 OpenAPI contract. Refs: #199
kaseywright
left a comment
There was a problem hiding this comment.
Only Items that I have are nits and really only a concern as the project moves into scaling behind a load balancer:
- Hard-coded constants. MAX_BUCKETS = 10_000 and the route-level windowMs: 60_000, max: 20 are not configurable. For a minimal guard this is fine, but we will want to expose them through env vars when the limiter is reused.
- The assumption that x-forwarded-for is always appended by a trusted proxy might be problematic later. We would want to make this configurable.
- Rate-limit hits are not logged. Adding a
logger.warnfor 429s would help detect abuse and debug false positives.
|
Thanks for the review @kaseywright! Handled as follows:
Heads-up: the push likely dismissed your approval — re-approve when convenient. 🙏 |
Per review feedback on #209: warn-log every rate-limit hit (client key, path, limit, window) so abuse and false positives are visible in App Insights. The other two review nits — env-configurable limits and a configurable trusted-proxy assumption — are deliberate scale-out follow-ups tracked in #210 (TODO added in code).
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/middlewares/rate-limit.test.ts (1)
85-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding coverage for the
MAX_BUCKETSeviction path.
src/middlewares/rate-limit.ts(lines 67-78) added sweep-then-evict-oldest logic when the bucket map hits 10k entries, but no test in this file exercises that branch. It's non-trivial (nested loops, break condition), worth a targeted regression test.Example test sketch
it('evicts oldest buckets once MAX_BUCKETS is reached', async () => { const middleware = rateLimit({ windowMs: 60_000, max: 1 }); const next = vi.fn(); for (let i = 0; i < 10_000; i++) { await middleware(createContext({ 'x-forwarded-for': `10.0.${i >> 8}.${i & 0xff}` }), next); } // The oldest client's bucket should have been evicted, allowing a fresh request through const revived = createContext({ 'x-forwarded-for': '10.0.0.0' }); await middleware(revived, next); expect(revived.json).not.toHaveBeenCalled(); });🤖 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.test.ts` around lines 85 - 117, Add a regression test in rate-limit.test.ts that covers the MAX_BUCKETS eviction path in rateLimit by driving the middleware past the 10,000-bucket threshold and then verifying the oldest bucket is evicted and can be reused. Use the existing createContext, rateLimit, and next setup, and target the sweep-then-evict logic in rateLimit so the nested loop/break branch is exercised.
🤖 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.
Outside diff comments:
In `@src/middlewares/rate-limit.test.ts`:
- Around line 85-117: Add a regression test in rate-limit.test.ts that covers
the MAX_BUCKETS eviction path in rateLimit by driving the middleware past the
10,000-bucket threshold and then verifying the oldest bucket is evicted and can
be reused. Use the existing createContext, rateLimit, and next setup, and target
the sweep-then-evict logic in rateLimit so the nested loop/break branch is
exercised.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 57e70478-8c1f-4f70-93e6-1916093a58d4
📒 Files selected for processing (4)
docs/proposals/security-and-export-hardening/01-auth-open-domains.mdsrc/domains/bibles/bible-texts/bible-texts.route.tssrc/middlewares/rate-limit.test.tssrc/middlewares/rate-limit.ts
Closes #199.
Decision (Kasey, 2026-06-26)
Anonymous access to the bible-texts/books reads is intentional, so per the agreed plan the bulk endpoint stays unauthenticated, gets its intent documented, and gains a rate limit as an abuse guard (not a substitute for auth).
What changed
rateLimitmiddleware (src/middlewares/rate-limit.ts) — minimal fixed-window, per-client-IP (firstx-forwarded-forhop; Azure App Service sets it), in-memory per process. Returns429 { message }withRetry-After, consistent with the global error envelope. better-auth already rate-limits/api/auth/*; this covers anonymous API routes and is reusable if other open endpoints appear.POST /bibles/{bibleId}/bulk-textsat 20 req/min per client — a full mobile sync needs 1–2 requests (up to 1200 chapters each), so this is generous for legit clients while blunting mass scraping. The429is documented in the OpenAPI responses, and the route description now states the intentional-anonymous decision.x-forwarded-for.01-auth-open-domains.md, the Azure Blob decision for the async pipeline recorded in03-…(Async USFM export pipeline hardening (pre-enablement) #196), and the README ticket table statuses refreshed.Notes / limits of the guard
GET .../chapters/{n}/texts, books, bible-books) are left un-limited for now; the bulk endpoint was the scraping primitive. Easy to extend with the same middleware if desired.Test plan
npm run typechecknpm run lintnpm run test— 85/85 pass (5 new middleware tests)Screenshots
1) OpenAPI reference — endpoint documents the decision + 429
The Scalar reference for
POST /bibles/{bibleId}/bulk-textsnow states the endpoint is intentionally anonymous (mobile sync feature) and rate-limited, and the429response ("Rate limit exceeded (20 requests per minute per client)") is part of the documented contract alongside 200/400/500.2) Live behavior — 20 allowed, then 429 with Retry-After
22 sequential anonymous requests from one client against the running API (seeded DB): requests 1–20 return
200(first returns Genesis 1's 31 verses), request 21+ returns429withRetry-After: 60and the standard{"message":"Too many requests"}envelope.Summary by CodeRabbit
Summary by CodeRabbit
New Features
429 Too Many RequestswithRetry-Afterguidance.Documentation
Tests
429responses.