Skip to content

fix(rate-limit): key the connector and profile-ingress limiters by the Cloudflare address (TASK-125) - #1883

Merged
lilyshen0722 merged 2 commits into
mainfrom
kai/task125-cf-key-fallback
Sep 25, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
kai/task125-cf-key-fallback

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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-101 records that behind nginx req.ip is
a 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 of integrationsRateLimitKey. The
    Slack OAuth callback (installables.ts:367, mounted at server.ts:251) is a browser redirect and
    never carries Authorization, so its 30/60s write budget was one bucket. It now defers to
    cloudflareIpRateLimitKeyGenerator (ipRateLimit.ts:49); the token branch is untouched, so callers
    that present a credential keep their tok:<sha256> bucket.
  • routes/users.ts:52 — profileWriteIngressLimit (120/15min) keyed on req.ip alone, mounted ahead
    of auth on PUT /profile (:164). Same swap. It predates TASK-110 (feat(v2): align Settings with artboard #1546) and passed that census
    because it sets a key, just not the Cloudflare one — this is the rateLimitDefaultKey.test.js
    KNOWN-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 integrationRateLimit header comment now states the rule instead.

Tests

__tests__/unit/middleware/integrationRateLimit.test.js (new, module-level): two header-free requests
with different cf-connecting-ip values get separate buckets, the same value shares one, and the token
branch still buckets two different headers apart. rateLimitDefaultKey.test.js gains the ingress site
as the eighth PINNED row. The #1867 witness's key comment
(discordChannelsRateLimit.test.js:12-15) is updated to match; its supertest flood carries no
cf-connecting-ip, so it still lands on req.ip and 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 max 30->31 -> 4 red ·
M4 comment out the new PINNED row -> 1 red · M5 the token branch keyed on req.ip -> 2 red · control
green, every revert verified by read-back.

Two disclosures, one now ruled. The PINNED guard is mine, added beyond the row's item 2, and wren
73921 ruled it exact rather than a floor: expect(PINNED).toHaveLength(8). A floor guards today's eight and
nothing 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 >= 60 floor stays
loose beside it: that one counts scanned code, which grows with the tree rather than by decision.)

Item 4's wording: I left the #1867 witness's test name alone (only its comment changed) rather
than 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 grep excluding tests: e3a32f32 = 24
ipKeyGenerator( / 23 ipKeyGenerator(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 e3a32f32 after #1867 merged — item 3 edits a file that PR adds — which collapsed the
stack to 5 files, +160/-10. Verified: 64 tests / 6 suites (the touched suites plus
routeRateLimitGuard, integrations.validation, discordBindingContainment); eslint . --ext .ts
0 errors / 1211 warnings, the base counts. Backend-only, so no version slot moves.

Gate: @vera.

…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
samxu01 force-pushed the kai/task125-cf-key-fallback branch from cdfb0c6 to edc68e0 Compare September 25, 2026 12:21
…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.
@lilyshen0722
lilyshen0722 added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit a7dea33 Sep 25, 2026
20 of 24 checks passed
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