Skip to content

fix(security): close the shot-render rate-limit bypass and gate it with a signed token - #9210

Merged
JSONbored merged 1 commit into
mainfrom
fix/9044-shot-render-security
Jul 27, 2026
Merged

fix(security): close the shot-render rate-limit bypass and gate it with a signed token#9210
JSONbored merged 1 commit into
mainfrom
fix/9044-shot-render-security

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

/loopover/shot is the only internet-facing route on the self-host topology, and its per-identity rate limiter keyed exclusively on cf-connecting-ip — correct on Cloudflare Workers, but blind behind a Cloudflare Tunnel to a plain Node process, where a caller-supplied header could be rotated to mint a fresh bucket every request (or share one "unknown-ip" bucket with everyone, if no header at all reached the origin).

  • Thread the genuine Node-runtime TCP peer address into the request env (server.ts, via @hono/node-server's own second FetchCallback argument, previously ignored) and make clientIp() trusted-proxy-aware: only honor cf-connecting-ip/X-Forwarded-For when the immediate peer is loopback (the only thing that can ever reach this deployment's tunnel-fronted port); otherwise key on the real peer address directly, never a shared "unknown-ip" bucket. Cloudflare Workers' existing behavior (no peer info) is unchanged.
  • Add a fixed-key, identity-independent global ceiling on the on-demand render mode specifically, so rotating the header can never manufacture more than one shared bucket regardless of how well IP attribution holds up in a given topology.
  • Gate the render (?url=) mode behind a signed, expiring token (shot-render-token.ts) minted when the review comment embeds the link, validated before the existing host-allowlist/SSRF checks (kept as defense-in-depth underneath, not replaced).
  • Add a best-effort DNS-resolution pin ahead of navigation, closing the common case of a public hostname whose A record resolves to a disallowed address; not a substitute for a real pinned connection, which Puppeteer/CDP interception has no API to force.
  • Fix a stale doc comment claiming this route is rate-limited as "normal" — it is "expensive".

Test plan

  • npx tsc --noEmit -p tsconfig.json --incremental false — clean
  • npm run db:migrations:check / npm run db:schema-drift:check / npm run branding-drift:check — all ok
  • npx vitest run test/integration/shot-route.test.ts test/unit/auth.test.ts test/unit/shot-render-token.test.ts test/unit/visual-capture.test.ts test/unit/visual-shot.test.ts test/unit/index.test.ts — 422 passed
  • New tests: rotating-identity bypass attempts still throttled by the global ceiling, trusted-proxy clientIp behavior (loopback vs non-loopback peer, header vs no-header), the signed token's mint/verify contract (valid/tampered/expired/wrong-url), and the DNS-pin's block/fail-open paths
  • Rebased onto latest main after landing (picked up fix(orb): close-authority integrity across four review-engine gaps #9206's close-authority fix, fix(test,docs): resync installation-health fixtures and self-host docs with the #9169 required-events expansion #9203 — no conflicts, re-verified)

Closes #9044

…th a signed token (#9044)

/loopover/shot is the only internet-facing route on the self-host topology, and its per-identity
rate limiter keyed exclusively on cf-connecting-ip -- correct on Cloudflare Workers, but blind
behind a Cloudflare Tunnel to a plain Node process, where a caller-supplied header could be
rotated to mint a fresh bucket every request (or share one "unknown-ip" bucket with everyone,
if no header at all reached the origin).

- Thread the genuine Node-runtime TCP peer address into the request env (server.ts, via
  @hono/node-server's own second FetchCallback argument, previously ignored) and make clientIp()
  trusted-proxy-aware: only honor cf-connecting-ip/X-Forwarded-For when the immediate peer is
  loopback (the only thing that can ever reach this deployment's tunnel-fronted port); otherwise
  key on the real peer address directly, never a shared "unknown-ip" bucket. Cloudflare Workers'
  existing behavior (no peer info) is unchanged.
- Add a fixed-key, identity-independent global ceiling on the on-demand render mode specifically,
  so rotating the header can never manufacture more than one shared bucket regardless of how well
  IP attribution holds up in a given topology.
- Gate the render (?url=) mode behind a signed, expiring token (shot-render-token.ts) minted when
  the review comment embeds the link, validated before the existing host-allowlist/SSRF checks
  (kept as defense-in-depth underneath, not replaced).
- Add a best-effort DNS-resolution pin ahead of navigation, closing the common case of a public
  hostname whose A record resolves to a disallowed address; not a substitute for a real pinned
  connection, which Puppeteer/CDP interception has no API to force.
- Fix a stale doc comment claiming this route is rate-limited as "normal" -- it is "expensive".

Tests: rotating-identity bypass attempts still throttled by the global ceiling, trusted-proxy
clientIp behavior (loopback vs non-loopback peer, header vs no-header), the signed token's mint/
verify contract (valid/tampered/expired/wrong-url), and the DNS-pin's block/fail-open paths.
@superagent-security

Copy link
Copy Markdown
Contributor

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

@JSONbored JSONbored self-assigned this Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Logic backtest

Replayed 0 historical case(s) for linked_issue_scope_mismatch through the base (e4a561b) and head (dd18eba) versions of its detection logic (corpus checksum 4f53cda18c2b).

Backtest comparison: linked_issue_scope_mismatch

Verdict: unchanged — no comparable axis moved.

Advisory only — this check never blocks merge (#8105).

@JSONbored
JSONbored merged commit 05ae04e into main Jul 27, 2026
5 of 6 checks passed
@JSONbored
JSONbored deleted the fix/9044-shot-render-security branch July 27, 2026 07:08
@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

❌ 4 Tests Failed:

Tests completed Failed Passed Skipped
22146 4 22142 21
View the top 3 failed test(s) by shortest run time
test/integration/orb-relay.test.ts > POST /v1/orb/relay/pull > REGRESSION (#4995): a DB error inside validateOrbRelayEnrollment's own lookup ALSO returns a clean 503 broker_error, not a framework 500 (the earlier of the two DB-touching calls in this handler)
Stack Traces | 0.012s run time
AssertionError: expected 500 to be 503 // Object.is equality

- Expected
+ Received

- 503
+ 500

 ❯ test/integration/orb-relay.test.ts:1160:24
test/integration/orb-onboarding.test.ts > Central Orb installation registry routes (/v1/internal/orb/installations) > tolerates a list query that omits results (rows.results ?? [])
Stack Traces | 0.0226s run time
SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON
 ❯ test/integration/orb-onboarding.test.ts:57:14
test/integration/orb-ingest.test.ts > Orb instance registry routes (/v1/internal/orb/instances) > tolerates a list query that omits results (rows.results ?? [])
Stack Traces | 0.0269s run time
SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON
 ❯ test/integration/orb-ingest.test.ts:427:14
test/integration/orb-relay.test.ts > POST /v1/orb/relay/register > REGRESSION (#4995): a DB error inside validateOrbRelayEnrollment's own lookup ALSO returns a clean 503 broker_error, not a framework 500 (the earlier of the two DB-touching calls in this handler)
Stack Traces | 0.0772s run time
AssertionError: expected 500 to be 503 // Object.is equality

- Expected
+ Received

- 503
+ 500

 ❯ test/integration/orb-relay.test.ts:155: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

Development

Successfully merging this pull request may close these issues.

orb(security): the one internet-facing route is the expensive headless-Chromium render, and its rate limiter is bypassable

1 participant