Skip to content

fix(rate-limit): close an unauthenticated DoS, a permanently-stuck limiter, and two outbound backoff defects (#9469, #9493, #9494) - #9504

Merged
JSONbored merged 1 commit into
mainfrom
fix/rate-limit-integrity
Jul 28, 2026
Merged

fix(rate-limit): close an unauthenticated DoS, a permanently-stuck limiter, and two outbound backoff defects (#9469, #9493, #9494)#9504
JSONbored merged 1 commit into
mainfrom
fix/rate-limit-integrity

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Three rate-limiting defects from the 2026-07-27 audit: one unauthenticated denial-of-service vector, one way the limiter can wedge permanently, and two ways outbound backoff misbehaves against GitHub.

Closes #9469
Closes #9493
Closes #9494

#9469 — an unauthenticated caller could silence a target installation's webhooks

/v1/github/webhook and /v1/orb/webhook keyed their rate-limit bucket on installation.id read from the request body before signature verification, with no IP component. The class is strict = 10 requests / 60 seconds.

So anyone could POST {"installation":{"id":<victim>}} ten times a minute — no credentials — and pin that installation's bucket. GitHub's genuine deliveries then receive 429 from the middleware (which runs ahead of the handler), and GitHub does not auto-redeliver, so the engine goes deaf for that installation. Cost to the attacker: one request every six seconds. Installation ids are low-entropy and discoverable.

The existing threat note reasoned that a spoofed id "grants no access, since HMAC verification still gates everything downstream" — correct about access, but sharing the bucket is the attack.

The key now folds in the connecting IP. #4891's requirement is preserved: brokered tenants behind one shared egress still get independent buckets, because the installation id continues to separate them. It also restores headroom #4891 removed by accident — GitHub delivers from many egress IPs, so a CI storm spreads across per-IP buckets again instead of contending for a single 10/60s window (the second half of #9469's report).

#9493 — a TTL-less key denies its bucket forever

The Redis limiter did INCR, then EXPIRE only when the count came back as 1 — two round trips. A process death, a dropped connection, or an EXPIRE that merely errored between them left the key with no TTL: the fixed window never resets, the counter climbs forever, and once past the limit every request on that key is denied until someone deletes it by hand. The code even papered over the symptom — a PTTL of -1 fell back to a full window, so the 429 kept promising "retry in 60s" indefinitely.

Worst case is strict:/v1/github/webhook:installation:<id>: a stuck key means that installation's webhooks 429 forever.

One Lua script now makes the increment and its expiry atomic. It also repairs a key that still has no TTL, so anything already stranded by the old path self-heals on next contact instead of needing manual intervention.

#9494 — outbound: a false-clear every 30 minutes, and a truncated Retry-After

App-JWT rows cleared real exhaustion backoffs. getAppInstallation authenticates with the App JWT, which has its own 5000/hr bucket — but recorded its response headers under installation:{id}. The admission readers take only the newest REST row per key, and refresh-installation-health runs unconditionally every 30 minutes. So an exhausted installation's parked jobs saw a healthy remaining≈4990, were re-admitted as a cohort, burned their retries against 403s, and re-parked — every 30 minutes for the whole exhaustion window. Now recorded under a distinct app-jwt key. #4506's requirement (these calls are visible to admission reads) is unchanged; only the attribution is fixed.

Retry-After was truncated into the window GitHub asked us to avoid. A secondary-limit Retry-After is typically 60s; the inline budget is 8s. The old code took Math.min(retryAfter, 8s) and retried up to 3 times inside the window — exactly what GitHub's docs warn can extend or escalate a secondary block. When the instructed wait exceeds the inline budget the response now surfaces instead, so the queue's existing handling applies: it honors the full Retry-After with jitter and defers sibling jobs for the same admission target.

The inline ladder is also jittered now. Up to QUEUE_CONCURRENCY (default 8) workers can trip the same limit within milliseconds, and a fixed 500/1000/2000 ladder had them retry in lockstep.

Tests

npx tsc --noEmit clean; 235 passed across selfhost-redis-ratelimit, auth, github-app, rate-limit, github-client.

New:

Three existing tests asserted the old behaviour and were updated rather than deleted, each with the rationale inline:

  • The webhook-keying test asserted "the SAME installation from two DIFFERENT IPs shares one bucket" — that assertion is the vulnerability. It now asserts the opposite, while keeping and strengthening Move GitHub-webhook rate-limit buckets from IP-keyed to installation-keyed #4891's actual requirement (two different installations from one IP stay separate, and both keys still carry the installation identity).
  • The #4506 invariant test asserted admissionKey: "installation:123"; it now asserts "app-jwt".
  • The backoff test asserted exact values; it now asserts the jittered range, plus the new no-jitter-for-instructed-waits case.

The redis test's fake was upgraded from three stub methods to a TTL-aware fake that models the counter and its expiry (including the -1 "exists, no TTL" shape), so the repair arm is genuinely exercised rather than mocked away.

Note

GITHUB_RATE_LIMIT_MAX_DELAY_MS (the 8s inline cap) is unchanged — the fix is to stop truncating into it, not to raise it. Long waits belong on the queue, which already has the machinery.

…miter, and two outbound backoff defects (#9469, #9493, #9494)

The inbound webhook bucket was keyed on installation.id read from the body BEFORE signature
verification, with no IP component -- so anyone could pin a chosen installation's 10/60s window
by POSTing {"installation":{"id":<victim>}} ten times a minute. GitHub's real deliveries then
429, and GitHub does not auto-redeliver, so the engine went deaf for that installation at a cost
of one unauthenticated request every six seconds. The prior threat note correctly observed that
a spoofed id grants no ACCESS; it missed that sharing the bucket IS the attack. Folding the
connecting IP into the key keeps #4891's tenant separation (the installation id still splits
brokered tenants behind one egress) while ensuring an attacker and GitHub never share a bucket.
It also restores headroom #4891 removed by accident: GitHub delivers from many egress IPs, so a
CI storm spreads across buckets again instead of contending for a single window.

The Redis limiter's INCR-then-conditional-EXPIRE is two round trips: a death, dropped
connection, or a failed EXPIRE between them left the key with no TTL, so the window never reset
and every request on that key was denied until manual deletion -- with the -1 PTTL fallback
papering over it by promising "retry in 60s" forever. One Lua script now makes the increment
and its expiry atomic, and repairs an already-TTL-less key so anything stranded by the old path
self-heals on next contact.

Outbound, getAppInstallation recorded its App-JWT response headers under installation:{id},
but the JWT has its own 5000/hr bucket -- so refresh-installation-health wrote a healthy
remaining count over a genuinely exhausted installation's newest observation every 30 minutes,
re-admitting parked jobs to burn retries against 403s and re-park. It now records under a
distinct app-jwt key. And a secondary-limit Retry-After (typically 60s) was truncated to the 8s
inline cap and retried up to three times inside the window GitHub asked us to stay out of,
which its own docs warn can extend the block; the response now surfaces so the queue's existing
jittered Retry-After handling applies. The inline ladder is jittered too, so concurrent workers
no longer retry in lockstep.
@loopover-orb

loopover-orb Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Caution

🛑 LoopOver review result - fixes required

Review updated: 2026-07-28 00:36:41 UTC

7 files · 1 AI reviewer · no blockers · CI failing · unstable

🛑 Suggested Action - Manual Review

  • Required AI review was skipped by a submitter-reputation downgrade: This repository requires blocking AI review, and the submitter's recent-submission signal downgraded this PR to deterministic-only checks. Those checks do not read code semantics, so the gate is held for human review instead of passing automatically.

Review summary
This PR fixes three real, well-traced defects: the webhook rate-limit bucket now folds in the connecting IP so an unauthenticated attacker can no longer pin a victim installation's bucket (src/auth/rate-limit.ts:243), the Redis limiter's INCR+conditional-EXPIRE race is replaced with an atomic Lua script that also self-heals a TTL-less key (src/selfhost/redis-ratelimit.ts), and outbound GitHub backoff now jitters and respects a Retry-After that exceeds the inline retry budget instead of truncating it (src/github/client.ts). The App-JWT-vs-installation-bucket attribution fix (githubRateLimitAdmissionKeyForAppJwt) is a genuine wiring bug caught correctly — recording JWT-authenticated headers under the installation key was overwriting real exhaustion signals every 30 minutes. Each claimed defect is fixed at its actual source (identity/bucketing layer for #9469, the Redis script for #9493, the retry-loop for #9494), and tests exercise the real code paths, including the stuck-key repair and the cross-app JWT bucket separation.

Nits — 7 non-blocking
  • src/github/client.ts: the inline retry budget cap (GITHUB_RATE_LIMIT_MAX_DELAY_MS) and jitter divisor (base/2) are unnamed magic-adjacent constants scattered across rateLimitRetryMs/exceedsInlineRetryBudget — a short comment tying them together would help a future reader.
  • The codecov/patch check failed at 95% vs a 99% target; worth checking which changed branch (likely the non-numeric TTL fallback arm in redis-ratelimit.ts or an edge in exceedsInlineRetryBudget) is uncovered before merge.
  • src/auth/rate-limit.ts: installationRateLimitIdentity now threads ipIdentity as a parameter only for the webhook-path branch — worth a one-line note that the other branches (relay, orb-bearer) intentionally ignore it, so a future reader doesn't wonder why it's unused there.
  • Confirm the codecov/patch gap covers the non-numeric-TTL fallback branch in redis-ratelimit.ts and the `instructed === null` false-arm of exceedsInlineRetryBudget, since both are real reachable branches.
  • Consider extracting the jitter formula in rateLimitRetryMs (src/github/client.ts) into a small named helper for readability, given it's now doing cap+jitter+Retry-After-precedence in one expression.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.
  • Required AI review was skipped by a submitter-reputation downgrade — Review this PR manually, or run AI review with a trusted override, before merging.

CI checks failing

  • codecov/patch — 95.00% of diff hit (target 99.00%)

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ⚠️ Gate result — Not blocking (Advisory; not blocking this PR.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9469, #9493, #9494
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (3 linked issues).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 14 registered-repo PR(s), 13 merged, 346 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 14 PR(s), 346 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Linked issue satisfaction

Partially addressed
The PR directly fixes the core unauthenticated bucket-collision vector by folding the connecting IP into the webhook rate-limit key, with tests confirming spoofed installation.id requests no longer share a bucket with genuine per-IP traffic. However it leaves several explicit deliverables from the issue undone: the webhook path still uses the blanket 10/min `strict` class rather than a resized CI-

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: not available
  • Official Gittensor activity: 14 PR(s), 346 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask &lt;question&gt; answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat &lt;question&gt; answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: hold · clause: success
  • config: 825d193000ac6c8c364bd6b48cd7df024faed8f78bd760d9f78fb26e82d8e324 · pack: oss-anti-slop · ci: failed
  • record: 0bd0c5648bf2622c426e286acde4e1b163acef7cf4c13e23d9b33ce002ad6b9c (schema v5, head 8e7a825)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.65%. Comparing base (6571a1b) to head (8e7a825).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/github/client.ts 92.30% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9504      +/-   ##
==========================================
- Coverage   89.55%   88.65%   -0.91%     
==========================================
  Files         843      843              
  Lines      110073   110082       +9     
  Branches    26194    26196       +2     
==========================================
- Hits        98573    97590     -983     
- Misses      10238    11520    +1282     
+ Partials     1262      972     -290     
Flag Coverage Δ
backend 93.62% <95.00%> (-1.65%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/auth/rate-limit.ts 98.48% <100.00%> (ø)
src/github/app.ts 97.99% <100.00%> (ø)
src/selfhost/redis-ratelimit.ts 100.00% <100.00%> (ø)
src/github/client.ts 99.28% <92.30%> (-0.35%) ⬇️

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 28, 2026
@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 28, 2026
@JSONbored
JSONbored merged commit b5cfc9a into main Jul 28, 2026
6 of 7 checks passed
@JSONbored
JSONbored deleted the fix/rate-limit-integrity branch July 28, 2026 00:47
JSONbored added a commit that referenced this pull request Jul 28, 2026
…9504/#9505 merges (#9509)

* test(gate): cover the fail-safe marker-clear path (#9462)

Mirrors the sibling visual-capture-satisfied fail-safe test: a rejecting clear must not throw
out of the pass, since a failed clear only means the gate stays deferred until the head moves.

(cherry picked from commit efa6472)

* refactor(gate): drop an unreachable spread arm and annotate the defensive head-SHA guard (#9462)

screenshotTableEvidenceUnresolved is computed as a plain boolean, so the undefined arm of its
conditional spread was dead code -- pass it directly. The head-SHA guard in the exhausted branch
is genuinely defensive (a recapture-preview job is only minted for a PR that had one), matching
the sibling mark write, so it carries a reason rather than a contrived test.

(cherry picked from commit 4f59499)

* test(ai): exclude two unreachable best-effort cleanup arms from coverage (#9479)

rm with force:true does not throw for a missing path, so the catch needs a filesystem-level
failure no unit test can portably induce; the unset-cwd guard is only reachable when mkdtemp
itself threw, i.e. no directory was ever created. Both annotated with their reason, matching
the file's existing convention.

(cherry picked from commit bf3efe6)

* test(ai): drive the real subprocess EPIPE path and the non-Error stall guard (#9476, #9479)

The EPIPE regression uses the existing real-subprocess harness rather than a stubbed spawn, so
the stdin error listener is genuinely exercised: a fake CLI exits immediately without draining
stdin, and the test asserts no uncaughtException reaches the process. Verified to fail without
the listener (it captures an uncaught EPIPE), which is precisely the crash that took down every
in-flight queue job in the container.

Also covers isStalledNoOutput rejecting a non-Error throw -- a provider adapter can reject with
a string, and misclassifying that as a deadline signal would silently skip the retry budget.

(cherry picked from commit c770cc1)

* test(rate-limit): cover the inline-retry break for an over-budget Retry-After (#9494)

Asserts the attempt COUNT, not just the classification: a 60s Retry-After yields exactly one
attempt instead of four, and a short one still uses the full inline budget so the break stays
narrow.

(cherry picked from commit 92813bf)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment