Skip to content

orb(federated): one allowlisted key owns the peer median — no instance dedup, no range checks, no freshness, and an unbounded response read on the dashboard path #9148

Description

@JSONbored

Problem

The federated peer-benchmark import trusts peer-supplied values far more than its own comments claim.
Four compounding gaps, all in the same path.

1. One allowlisted key mints unlimited Sybil peers and sets the median to any value

verifyFederatedBundle accepts a bundle if it verifies under any allowlisted key, and nothing binds a
key to an instanceId — the code explains why it can't ("instanceId is unauthenticated until a key
verifies"
). importPeerBundles (src/orb/federated-import.ts:128-136, :176-183) then pushes every
verifying bundle into accepted with no dedup on instanceId, and the benchmark does
accepted.map(b => b.mergePrecision).filter(≠null).sort()percentile(…, 50).

So a holder of one allowlisted key signs 99 bundles with 99 distinct instanceIds and owns the median
outright. The header comment at :10-13 asserts the opposite — "a Sybil attack is self-limiting by
construction: forging peers requires the RECEIVING operator to have added the attacker's keys
themselves"
— which is true of keys and false of peers. The companion defence ("MEDIAN, NOT MEAN — a
bounded number of outliers cannot drag a median") assumes a bound this code never enforces.

Two aggravating details: peerCount is documented as "how many peers actually contributed" but counts
bundles (peerMergePrecisions.length), so the card renders "99 peers" for one; and the doc's claim
that only peers "that itself cleared MIN_DECIDED" contribute is enforced nowhere — a peer self-reports
decided: 3, mergePrecision: 1.0 and it counts.

2. Fields are finiteness-checked but never range-checked

isBundleBodyShaped's numeric predicate (federated-import.ts:75-94) is
typeof value === "number" && Number.isFinite(value). Nothing constrains mergePrecision to [0,1],
decided/windowDays to positive, or cycleP95Ms to sane. A signed bundle carrying
mergePrecision: -3 or 1e308 passes rejectionFor unchanged and lands in the median.

3. No freshness or replay protection

generatedAt and windowDays are signed but never read: rejectionFor checks schema version, shape,
and signature — nothing else. No freshness window, no monotonicity against a previously-seen bundle from
the same instance, no persisted state. A collector — explicitly untrusted ("a hostile collector is
indistinguishable from 'no peers yet'"
, federated-collector.ts:169-170) — can re-serve one favourable
year-old bundle forever, and mixing windowDays: 7 with windowDays: 365 bundles into one median is
uncaught despite that field's own docstring ("so peers only median equal-length windows").

4. The peer response has no byte ceiling, and is fetched inside a request

federated-collector.ts:180-185 does const payload: unknown = await response.json() with no
Content-Length pre-check, no streamed cap, and no array-length cap — only a 5s AbortSignal.timeout.
Every sibling reader in this subsystem has one: readOrbIngestBody (1 MiB), readOrbRelayRegisterBody
(4 KiB), boundedResponseText (2 KB). And the only production caller is buildFederatedBenchmark at
src/api/routes.ts:1716 — inside the awaited body of the maintainer dashboard GET, which therefore
also pays the full 5s latency on every load whenever the collector is slow.

Impact

The maintainer-facing "you are behind the peer median" verdict becomes attacker-chosen, or simply
nonsense. Display-only today (no gate path — verified), but it is decision-support. Plus a memory/stall
vector on an authenticated route.

Requirements

  • Key accepted by instanceId (last bundle per instance wins) and cap contributions per verifying key.
  • Enforce decided >= MIN_DECIDED receiver-side instead of trusting the sender's null.
  • Range-validate every numeric field in isBundleBodyShaped.
  • Reject bundles whose generatedAt falls outside a bounded window of now; require windowDays to
    match the local window; persist a per-instance high-water mark to detect rollback.
  • Read the response through the same bounded reader the ingest path uses; cap array length; move the pull
    off the request path onto the background tick.
  • Fix peerCount to count instances, and correct the two header comments that overstate the defences.

Test Coverage Requirements

99%+ patch coverage, branch-counted; adversarial tests for N-bundles-one-key, out-of-range values, a
stale generatedAt, and an oversized response.

Links & Resources

  • src/orb/federated-import.ts ~10-13, ~75-94, ~128-144, ~176-183; src/orb/federated-collector.ts ~169-185;
    src/orb/federated-benchmark.ts ~53-66; src/api/routes.ts ~1715-1720
  • Feature is currently inert (federatedIntelligence absent from the live manifest) but wired —
    buildFederatedBenchmark runs on every maintainer dashboard load, so one YAML key arms it.

maintainer-only — federated trust boundary.

Metadata

Metadata

Assignees

Labels

maintainer-onlyOwner-only work — yields no Gittensor points.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions