Repository navigation
fix(rate-limit): key the connector and profile-ingress limiters by the Cloudflare address (TASK-125) - #1883
Merged
Conversation
…e Cloudflare address (TASK-125) `integrationsRateLimitKey`'s no-header branch and `routes/users.ts`'s profile-write ingress cap both keyed on `req.ip`, which is a cloudflared pod address — one of two — for every external caller (`server.ts:94-101`). The connector write budget (30/60s) is reachable by the Slack OAuth callback, a browser redirect that carries no Authorization header, so that budget was one instance-wide bucket; the ingress cap (120/15min) is mounted ahead of auth on PUT /profile and had the same shape. Both now defer to `cloudflareIpRateLimitKeyGenerator`, which owns the `cf-connecting-ip`-then- `req.ip` rule, so TASK-120's opt-out covers them when it lands with no further edit. Not a boundary and not claimed as one: the key is taken before any route's auth check runs, so the token branch buckets per PRESENTED credential and the address branch per recorded address — each rotatable by the caller. Tests: the new module-level `integrationRateLimit.test.js` loads the real module (the route suites all stub it to a pass-through) and witnesses the write budget's count, separate buckets for two presented tokens, and the CF-then-ip precedence at both the key and the request level. `rateLimitDefaultKey.test.js` pins `profileWriteIngressLimit` as an eighth site to the exact expression, and gains a length floor so a row deleted from PINNED is a failure rather than silently fewer passes. The other `ipKeyGenerator(req.ip)` fallbacks in backend/ are TASK-126, not this change.
samxu01
force-pushed
the
kai/task125-cf-key-fallback
branch
from
September 25, 2026 12:21
cdfb0c6 to
edc68e0
Compare
…3921) `expect(PINNED).toHaveLength(8)` rather than `>= 8`. A floor guards the eight sites present today and nothing beyond them: a ninth site added later passes without its adder deciding anything, which is the reverse of what a curated list is for. Exact length makes growth and shrinkage both a visible edit to the number. The `configs >= 60` floor stays loose beside it — that one counts scanned code, which grows with the tree rather than by decision (wren 73921). Ledger re-run at this head: M4 (comment out a PINNED row) still reddens this witness by name, and M4b (append a row without raising the number) reddens it too — the direction a floor cannot see.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two credential-free lockouts, keyed by the Cloudflare address
Both limiters below sit ahead of auth, so the request that reaches their no-credential branch is a
browser redirect or a header-free client — and
server.ts:94-101records that behind nginxreq.ipisa cloudflared pod address for every external caller. Each of these was therefore one instance-wide
bucket, the TASK-110 class: a stranger could spend everyone's budget without presenting anything.
middleware/integrationRateLimit.ts:17— the no-header branch ofintegrationsRateLimitKey. TheSlack OAuth callback (
installables.ts:367, mounted atserver.ts:251) is a browser redirect andnever carries
Authorization, so its 30/60s write budget was one bucket. It now defers tocloudflareIpRateLimitKeyGenerator(ipRateLimit.ts:49); the token branch is untouched, so callersthat present a credential keep their
tok:<sha256>bucket.routes/users.ts:52—profileWriteIngressLimit(120/15min) keyed onreq.ipalone, mounted aheadof auth on
PUT /profile(:164). Same swap. It predates TASK-110 (feat(v2): align Settings with artboard #1546) and passed that censusbecause it sets a key, just not the Cloudflare one — this is the
rateLimitDefaultKey.test.jsKNOWN-LIMIT site, now pinned by name as the eighth entry beside the seven.
The key is per presented credential, not per caller
The key is taken before any route's auth runs, so two requests carrying the same header share a bucket
whether or not that header authenticates anything. No comment or test name in this PR says the limiter
bounds unauthenticated traffic; the
integrationRateLimitheader comment now states the rule instead.Tests
__tests__/unit/middleware/integrationRateLimit.test.js(new, module-level): two header-free requestswith different
cf-connecting-ipvalues get separate buckets, the same value shares one, and the tokenbranch still buckets two different headers apart.
rateLimitDefaultKey.test.jsgains the ingress siteas the eighth PINNED row. The
#1867witness's key comment(
discordChannelsRateLimit.test.js:12-15) is updated to match; its supertest flood carries nocf-connecting-ip, so it still lands onreq.ipand still passes.Ledger, baseline 19/19, no survivors: M1 revert site 1 -> 3 red (2 independent; the third is
downstream of the collapsed bucket) · M2 revert site 2 -> 1 red · M3 the shipped
max30->31 -> 4 red ·M4 comment out the new PINNED row -> 1 red · M5 the token branch keyed on
req.ip-> 2 red · controlgreen, every revert verified by read-back.
Two disclosures, one now ruled. The
PINNEDguard is mine, added beyond the row's item 2, and wren73921 ruled it exact rather than a floor:
expect(PINNED).toHaveLength(8). A floor guards today's eight andnothing beyond them — a ninth site would arrive without its adder deciding anything — so growth and
shrinkage are both a visible edit to the number now. The ledger re-ran at that head in both directions:
M4 (drop a pinned row) and M4b (append a row without raising the number) each redden the witness by
name; M4b additionally reddens the fake row's own pin test, by design. (The
configs >= 60floor staysloose beside it: that one counts scanned code, which grows with the tree rather than by decision.)
Item 4's wording: I left the
#1867witness's test name alone (only its comment changed) ratherthan rename a test another PR just added; also offered for wren to overrule.
Blast radius
The other 20
ipKeyGenerator(req.ip)fallbacks are not this PR: TASK-126 audits them, per site,against who legitimately reaches each one. Measured with
git grepexcluding tests:e3a32f32= 24ipKeyGenerator(/ 23ipKeyGenerator(req.ip)-> this branch = 22 / 21, the two lines named above.Both sites are now CF-keyed, so TASK-120's opt-out covers them with no further edit.
Rebased onto
e3a32f32after #1867 merged — item 3 edits a file that PR adds — which collapsed thestack to 5 files, +160/-10. Verified: 64 tests / 6 suites (the touched suites plus
routeRateLimitGuard,integrations.validation,discordBindingContainment);eslint . --ext .ts0 errors / 1211 warnings, the base counts. Backend-only, so no version slot moves.
Gate: @vera.