Skip to content

fix(security): close a guardrail bypass, a repo-scope auth gap, and fail-open telemetry ingest#9112

Merged
JSONbored merged 1 commit into
mainfrom
fix/9017-9045-9046-security
Jul 26, 2026
Merged

fix(security): close a guardrail bypass, a repo-scope auth gap, and fail-open telemetry ingest#9112
JSONbored merged 1 commit into
mainfrom
fix/9017-9045-9046-security

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Three security fixes. Each is applied in the form that prevents the class, not just the instance — every one of these was a single omission in a place where three sibling call sites got it right.

#9017 (GHSA-rjhf-3xrf-j72w) — guardrail bypass via silent file-list truncation. githubPaginatedList stops at its 10×100 cap and returns the truncated list with no truncation flag. isGuardrailHit failed safe only on an empty list, so a truncated non-empty list that happened to omit the guarded file read as "no guardrail hit" — a PR padded past 1000 files could hide a .github/workflows/** edit from the hard guardrail and auto-merge it (arbitrary CI execution).

A list at/above the cap is now unverifiable → fails safe, which doubles as the hard changed-file ceiling the advisory also asks for. Deliberately placed inside isGuardrailHit rather than threading a truncation flag through the fetch layer: that way no future caller can obtain paths some other way and forget to pass the flag. The fetch layer additionally warns so the truncation is visible rather than silent.

#9045 — maintainer-packet route skipped the MCP repo allowlist. It gated only on requireStaticProtectedApiToken, which admits any static identity including the shared, end-user-obtainable mcp token, and never called isMcpReadRepoAllowed. Its three siblings each hand-rolled that check; this one omitted it — so the HTTP surface granted what the MCP surface denied for the same token, exposing every issue, PR, file, review and check summary for any repo. MCP_READ_REPO_ALLOWLIST is fail-closed by default (unset ⇒ deny all), so the intended posture was "deny everything" while this route allowed everything.

Rather than adding a fourth copy, the check is folded into the gate (optional repoFullName) and the three hand-rolled copies removed. A source-level drift test fails if any repo-scoped route calls the gate without it — verified by reintroducing the original omission and confirming the test names the exact route.

#9046 — telemetry ingest fails open. Both collectors returned true for an unset token (the shipped default), so anyone with network access could POST batches — and Orb's batches feed the published accuracy numbers. Network isolation was doing all the work, which the planned hosted Orb removes. They were separate near-identical copies, which is precisely how AMS ended up both fail-open and missing from the strict rate class. Now one shared isAuthorizedIngest (per-product secret still passed in, so credentials rotate independently), and /v1/ams/ingest joins /v1/orb/ingest in the strict rate class.

Closes #9017
Closes #9045
Closes #9046

Standardization

Per the AMS/ORB consistency goal, the two duplicated ingest guards are now a single shared helper — the duplication is what let AMS silently drift on two separate axes. Any future collector route gets the correct posture by calling it instead of hand-rolling one.

Deliberately not included

#9046 also suggests moving /metrics behind a separate admin port/interface. That's a deployment-topology decision for the operator rather than a code change I should make unilaterally, so I left it on the issue for a follow-up. The metrics-render caching suggestion is likewise deferred — it's a perf change, not a security boundary.

Test plan

  • 3 guardrail regressions: fails safe at the cap with no listed path hitting a glob (the exact attack), unchanged just under the cap, and still permissive when no guardrails are configured (the ceiling is a verification rule, not a blanket PR-size limit)
  • Fetch-layer test proving the cap-exhaustion warning is emitted and names the PR
  • 6 route-security tests: all four repo-scoped routes deny an unallowlisted mcp token and admit an allowlisted one (the guard scopes, it doesn't blanket-deny); operator tokens stay unscoped; both ingest endpoints reject an unset token, reject a wrong bearer, and accept a correct one
  • Structural drift guard — verified it fails when orb(security): maintainer-packet route skips the MCP repo allowlist its three sibling routes enforce (IDOR) #9045's omission is reintroduced, reporting /v1/repos/:owner/:repo/pulls/:number/maintainer-packet by name
  • 100% line and branch coverage on all 83 added lines (verified by intersecting the diff's added line numbers against lcov)
  • tsc --noEmit clean; 987/987 across 14 suites; all 9 drift gates green

…ail-open telemetry ingest

Three security fixes, each applied in the form that prevents the whole class
rather than the one instance.

#9017 (GHSA-rjhf-3xrf-j72w) -- githubPaginatedList stops silently at its
10-page x 100-file cap and returns the TRUNCATED file list with no truncation
flag. isGuardrailHit failed safe only on an EMPTY list, so a truncated non-empty
list that happened to omit the guarded file read as "no guardrail hit": a PR
padded past 1000 files could hide a .github/workflows/** or config edit from the
hard guardrail and auto-merge it -- arbitrary CI execution, the highest-value
target in the system. A list at/above the cap is now UNVERIFIABLE and fails safe,
which also serves as the hard changed-file ceiling the advisory asks for (a
legitimate contributor PR here is never 1000 files). Putting the rule INSIDE
isGuardrailHit rather than threading a truncation flag through the fetch layer
means no future caller can obtain paths some other way and forget to pass it.
The fetch layer additionally warns so the truncation is visible, matching what
the GraphQL supplement paths already do at their caps.

#9045 -- GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet gated only
on requireStaticProtectedApiToken, which admits ANY static identity including the
shared, end-user-obtainable `mcp` token, and never checked isMcpReadRepoAllowed.
Its three sibling repo-scoped routes each hand-rolled that check; this one simply
omitted it -- so the HTTP surface granted what the MCP surface denied for the
same token, exposing every issue, PR, file, review, and check summary for ANY
repo. MCP_READ_REPO_ALLOWLIST is fail-closed by default (unset ⇒ deny all), so
the intended posture was "deny everything" while this route allowed everything.
Rather than adding a fourth copy of the check, it is folded INTO the gate via an
optional repoFullName argument and the three hand-rolled copies are removed; a
source-level drift test fails if any repo-scoped route calls the gate without it.

#9046 -- both telemetry-collector ingest endpoints returned true for an UNSET
token (the shipped default), so anyone with network access could POST batches,
and Orb's batches feed the PUBLISHED accuracy numbers. Network isolation was
doing all the work, which the planned hosted Orb removes. They were separate
near-identical copies, which is exactly how AMS ended up both fail-open AND
missing from the strict rate class; they are now ONE shared isAuthorizedIngest
helper (per-product secret still passed in, so credentials rotate
independently), and /v1/ams/ingest joins /v1/orb/ingest in the strict class.

Closes #9017
Closes #9045
Closes #9046

Not included: #9046's suggestion to move /metrics behind a separate admin
port/interface is a deployment-topology decision for the operator, not a code
change I should make unilaterally -- left on the issue for a follow-up.

Tests: 3 guardrail regressions (fails safe at the cap, unchanged just under it,
still permissive with no guardrails configured), a fetch-layer truncation-warning
test, 6 route-security tests (all four repo-scoped routes deny an unallowlisted
mcp token and admit an allowlisted one; operator tokens stay unscoped; both
ingest endpoints reject an unset token, reject a wrong bearer, and accept a
correct one), and a structural drift guard verified to fail when the original
#9045 omission is reintroduced. 100% line+branch coverage on all 83 added lines;
987/987 across 14 suites; all 9 drift gates green.
@superagent-security

Copy link
Copy Markdown
Contributor

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
loopover-ui fca9f55 Commit Preview URL

Branch Preview URL
Jul 26 2026, 08:27 PM

@JSONbored JSONbored self-assigned this Jul 26, 2026
@JSONbored
JSONbored merged commit 6dc0e3c into main Jul 26, 2026
4 of 5 checks passed
@JSONbored
JSONbored deleted the fix/9017-9045-9046-security branch July 26, 2026 20:37
@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 26, 2026
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

❌ 13 Tests Failed:

Tests completed Failed Passed Skipped
22113 13 22100 21
View the top 3 failed test(s) by shortest run time
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > optional collector token: open when unset; enforced once AMS_INGEST_TOKEN is set
Stack Traces | 0.00547s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/ams-ingest.test.ts:159:50
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > optional collector token (#1285): open when unset; enforced once ORB_INGEST_TOKEN is set
Stack Traces | 0.00682s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/orb-ingest.test.ts:356:50
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 400 for invalid JSON
Stack Traces | 0.00759s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/orb-ingest.test.ts:318:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 400 for invalid JSON
Stack Traces | 0.00769s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/ams-ingest.test.ts:138:24
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > REGRESSION (#8330): a dropped connection mid-upload returns the same clean 413, not a framework 500
Stack Traces | 0.00796s run time
AssertionError: expected 401 to be 413 // Object.is equality

- Expected
+ Received

- 413
+ 401

 ❯ test/integration/orb-ingest.test.ts:346:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 400 for an empty body
Stack Traces | 0.00799s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/ams-ingest.test.ts:144:24
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 413 when the body exceeds the ingest byte ceiling
Stack Traces | 0.00833s run time
AssertionError: expected 401 to be 413 // Object.is equality

- Expected
+ Received

- 413
+ 401

 ❯ test/integration/orb-ingest.test.ts:330:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 413 when the body exceeds the shared ingest byte ceiling
Stack Traces | 0.00984s run time
AssertionError: expected 401 to be 413 // Object.is equality

- Expected
+ Received

- 413
+ 401

 ❯ test/integration/ams-ingest.test.ts:150:24
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 400 for an empty body
Stack Traces | 0.0124s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/orb-ingest.test.ts:324:24
test/integration/orb-oauth.test.ts > GET /v1/orb/oauth/callback (post-install landing) > the new exemption + rate class are path-specific (a later orb path still routes)
Stack Traces | 0.0203s run time
AssertionError: expected [ 400, 413 ] to include 401
 ❯ test/integration/orb-oauth.test.ts:36:24
test/integration/orb-ingest.test.ts > Orb instance registry routes (/v1/internal/orb/instances) > lists ingested instances as unregistered with their stored-signal count
Stack Traces | 0.022s run time
AssertionError: expected [] to deeply equal [ ObjectContaining{…} ]

- Expected
+ Received

- [
-   ObjectContaining {
-     "instanceId": "inst-a",
-     "registered": false,
-     "signalCount": 1,
-   },
- ]
+ []

 ❯ test/integration/orb-ingest.test.ts:378:23
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 200 + accepted count for a valid batch
Stack Traces | 0.0274s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/orb-ingest.test.ts:312:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 200 + accepted count for a valid batch
Stack Traces | 0.0406s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/ams-ingest.test.ts:132:24

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

None yet

1 participant