Skip to content

feat: wire CF rate-limit bindings on Workers deploy#91

Merged
FrkAk merged 3 commits into
mainfrom
feat/mymr-167-cf-rate-limit-wiring
May 22, 2026
Merged

feat: wire CF rate-limit bindings on Workers deploy#91
FrkAk merged 3 commits into
mainfrom
feat/mymr-167-cf-rate-limit-wiring

Conversation

@FrkAk
Copy link
Copy Markdown
Owner

@FrkAk FrkAk commented May 22, 2026

Summary

Task Reference: [MYMR-167]

Wires CloudflareRateLimitBackend to the Worker's env.RATE_LIMIT_API and env.RATE_LIMIT_AUTH bindings on the Workers deploy so middleware throttling uses CF's per-POP shared, eventually-consistent rate-limit binding instead of per-isolate MemoryRateLimitBackend. Adds a middleware-layer auth-path throttle as defense-in-depth on top of Better-Auth's existing in-memory customRules. Self-host keeps the memory backend by absence of bindings. Server actions stay on the per-isolate memory backend via a dedicated "actions" slot so their tight per-action caps are honored exactly.

  • lib/api/rate-limit.ts — adds bindingKey?: 'api' | 'auth' to RateLimitRule, replaces the single _backend with kind-parameterized _backends.{api,auth,actions}, parameterizes getBackend(kind) and setBackend(kind, backend). Prepends two auth rules (/api/auth/sign-in/*, /api/auth/sign-up/*) ordered most-specific-first so matchRule resolves them before the catch-all /api/*. Documents the rule.max == binding.simple.limit invariant.
  • lib/api/rate-limit-cf.tscheck() wraps the binding RPC in try/catch with a structured console.warn on error. New failOpen?: boolean constructor option (defaults true) selects per-binding behavior: true returns allowed: true so a degraded subsystem doesn't take the app offline; false returns allowed: false so an outage cannot widen a brute-force window. Docstrings explain the composite-key quirk and the advisory-only nature of remaining / resetIn (the binding API exposes only {success}).
  • lib/actions/rate-limit-action.ts — routes checkActionRateLimit to getBackend("actions"). The actions slot is intentionally never CF-bound; server-action callers (perUserMax 3–30) need tighter enforcement than any single CF binding's simple.limit provides, so per-isolate memory honors them exactly.
  • middleware.ts — removes the blanket /api/auth/* skip. matchRule(pathname) now routes auth paths to RATE_LIMIT_AUTH via rule.bindingKey, and the rest of /api/* to RATE_LIMIT_API.
  • worker-cf.ts — wraps the OpenNext handler in a one-shot init that registers both CF backends via setBackend(kind, ...) on the first request through the Workers entry. AUTH binding wired failOpen: false; API binding stays default failOpen: true. Idempotent via _rateLimitInitialized flag, amortized to a single binding-pointer assignment per isolate spawn. Emits a structured rate_limit_init log for wrangler tail verification.
  • tests/api/rate-limit-cf.test.ts (new) — covers (1) getBackend() default-kind behavior with instanceof MemoryRateLimitBackend strengthening, (2) independent api / auth / actions slots after setBackend, (3) auth rules taking precedence over the catch-all in matchRule, (4) CloudflareRateLimitBackend.check fail-open default on binding RPC error, (5) fail-closed branch when failOpen: false. beforeEach resets backend slots so test order is independent.

Doc-verified design decisions

  • CF rate-limit binding is per-POP, eventually consistent (counters cached per-isolate-machine within a POP, async-synced to a POP-level backing store). Not globally consistent — multi-POP attackers can still get ~limit × M POPs. Mitigation lives at WAF / Bot Fight, out of scope here.
  • binding.limit({key}) returns only {success}; simple.limit and simple.period are fixed at deploy time in wrangler.jsonc:ratelimits[]. The composite-key ${key}:${max}:${windowSeconds} workaround in rate-limit-cf.ts is genuinely required to partition multiple rules within a single binding. Because simple.limit is the only enforced cap, rule.max MUST equal it or RateLimit-Policy headers misrepresent runtime behavior — enforced as an invariant in the RateLimitRule docstring, and /api/mcp (100) + /api/auth/sign-up/* (5) now align with their respective bindings.
  • CF docs do not document the binding's RPC-error semantics. The fail-open / fail-closed choice is a deliberate availability vs. security tradeoff, not platform conformance: API stays fail-open (don't kill the app on an internal hiccup); AUTH is fail-closed (don't widen a brute-force window during an outage).
  • Better-Auth v1.6.11 exposes rateLimit.customStorage = {get, set} storing {count: integer, lastRequest: bigint} — structurally incompatible with the CF binding's {success} return, so backing BA directly with the CF binding is not possible. Layered defense-in-depth at the middleware layer ON TOP of BA's customRules is the correct architecture; BA continues to enforce sign-up at 3/60 in-process per isolate even though the AUTH binding allows 5/60.
  • @opennextjs/cloudflare is NOT imported into middleware.ts (which runs on both self-host and Workers builds) because the package is the existing .workers.ts runtime-split dependency; importing it directly would break the self-host bundle. The wrap stays in worker-cf.ts.
  • Pre-auth IP keying for /api/auth/sign-in/* and /api/auth/sign-up/* is a deliberate deviation from CF's general guidance against IP keys — there is no other key available before authentication, and brute-force defense by IP is the field standard.

Type of change

  • New feature
  • Refactor / cleanup
  • Bug fix (post-review: action-rate-limit regression, AUTH fail-closed, header honesty)

Testing

  • Tested locally with bun run dev (typecheck + lint + full test suite below)
  • Linting passes (bun run lint)
  • Typecheck passes (bun run typecheck)
  • bun test — 499/499 pass across 54 files (5 tests in tests/api/rate-limit-cf.test.ts, the existing tests/auth/rate-limit.test.ts still passes confirming the layered defense doesn't disturb BA's in-process throttle)
  • wrangler dev smoke — defer to operator (HOTL); plan AC feat: update design language #3 expects: unauthenticated / → 307 redirect to /sign-in BEFORE the rate-limit branch runs; /sign-in → 200 + CSP header; /api/project/<not-a-uuid> → 400; 101 rapid authenticated calls to /api/project/<uuid> — last is 429 with RateLimit-Policy: 100;w=60 + Retry-After; 6 rapid calls to /api/auth/sign-in/email from the same IP — last is 429.
  • Production verificationwrangler tail --format json | grep rate_limit_init after deploy should show a single emission per isolate spawn confirming both bindings registered.

Notes for reviewer

  • One real-time bug fix during implementation: the initial draft used exact-match rules (/api/auth/sign-in) which would never match Better-Auth's actual endpoints (/api/auth/sign-in/email). Switched to prefix patterns (/api/auth/sign-in/*, /api/auth/sign-up/*) and the test exercises real BA paths plus /api/auth/get-session (which correctly falls through to the API bucket).
  • Types in worker-cf.ts are file-local interfaces (WorkerEnv, WorkerCtx) rather than the auto-generated CloudflareEnv / ExportedHandler from cloudflare-env.d.ts. That file is excluded from typecheck (tsconfig.json:33) and importing @cloudflare/workers-types directly is banned by no-restricted-imports in eslint.config.* — its ambient declarations clobber DOM Request / Response and break unrelated browser tests. Project convention is file-local stubs, see lib/realtime/broker-do.ts:30-36 for the canonical pattern. The local interfaces structurally match what workerd passes; no functional difference, just smaller blast radius. Docstrings on both WorkerEnv and CloudflareRateLimitBinding now cite this convention so future readers don't try to ‟fix" it.
  • AUTH-binding fail-closed availability tradeoff: a sustained AUTH binding outage will 429 sign-ins on isolates that have cached the binding handle. BA's per-isolate counter is the next-layer defense if it ever needs to take over. The alternative (the original fail-open) means an outage IS the brute-force window. Picked the security side; flag if the availability cost is unacceptable in practice.

Post-review changes (latest commit)

Addresses concerns from inline review of the original 5-file diff:

  1. Action rate-limit regression — pre-fix, checkActionRateLimit routed through the CF API binding (simple.limit: 100) and silently relaxed every tight caller (oauth.revoke_all: 3 → 100, joinTeamByCode: 5 → 100, profile.update: 10 → 100, oauth.revoke: 20 → 100). Added the "actions" backend kind, never CF-bound, so actions stay on per-isolate memory with rule limits honored exactly. This is pre-PR enforcement, restored.
  2. AUTH fail-closed — added failOpen ctor option; AUTH binding wired failOpen: false so a binding outage cannot become a hole in the brute-force defense. Updated docstring to drop the unsupported "matches CF's documented platform fail-open behavior" claim — CF docs do not specify RPC-error semantics.
  3. Header honestyrule.max is enforcement-irrelevant under the CF binding (only simple.limit from wrangler.jsonc is enforced); mismatched max only succeeded in misleading clients via the RateLimit-Policy header. Aligned /api/mcp 60→100 and /api/auth/sign-up/* 3→5 to match their bindings. Added explicit invariant to the RateLimitRule docstring. BA's customRules continue to tighten sign-up to 3/60 in-process; follow-up tracked to add a dedicated 3/60 binding for middleware-layer parity.
  4. Test hardening — added beforeEach reset of all backend slots (test order was previously load-bearing), strengthened the default-kind test with instanceof MemoryRateLimitBackend, added coverage for the new fail-closed branch.
  5. Nits — dropped redundant ?? "api" in middleware (the helper's default param covers it); added one-line comment by _rateLimitInitialized clarifying it is binding-pointer state (not request-scoped) so it doesn't trip a "no global request state" Workers-anti-pattern flag.

Follow-up tasks worth filing (out of scope here): resolve two-sources-of-truth between RATE_LIMIT_RULES and wrangler.jsonc:ratelimits[]; declare a dedicated 3/60 binding for sign-up so middleware-layer enforcement matches BA; evaluate better-auth-cloudflare for KV-backed secondary storage; re-evaluate RATE_LIMIT_AUTH bucket sizing once production traffic data lands; revisit cf-binding for actions once per-action sizing is justified.

@FrkAk FrkAk requested review from ZeyNor and ulascanzorer as code owners May 22, 2026 19:02
@FrkAk FrkAk self-assigned this May 22, 2026
@FrkAk FrkAk merged commit 7fb8d2c into main May 22, 2026
4 checks passed
@FrkAk FrkAk deleted the feat/mymr-167-cf-rate-limit-wiring branch May 22, 2026 21:57
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