Skip to content

fix(ic-agent): only route canisters by root-attested subnet ranges - #743

Merged
lwshang merged 1 commit into
mainfrom
fix/subnet-id-range-cache-poisoning
Sep 16, 2026
Merged

lwshang merged 1 commit into
mainfrom
fix/subnet-id-range-cache-poisoning

Conversation

@lwshang

@lwshang lwshang commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Canister-to-subnet resolution now only uses canister ranges that came from an NNS-root-signed delegation.

Background

Two paths fetch subnet info, and they drew canister ranges from different certificates:

  • fetch_subnet_by_canister — from the NNS-root-signed delegation, checking that they contain the canister.
  • fetch_subnet_by_id — from the certificate's outer tree, which the subnet signs itself, and wrote them into the shared canister index.

Only the first source is authoritative for deciding which subnet may answer for a given canister, so the second should not have fed that index. Ranges of the second kind reached get_subnet_by_canister, whose canister_ranges.contains guard then re-checked against that same self-attested set.

The ranges can't simply be read from the delegation on the by-ID path. For a subnet-scoped read_state the delegation attests only /subnet/<id>/public_key and /time — confirmed against this repo's own mainnet fixture agent_test/uzr34_node_keys.bin, where the ranges appear solely in the outer tree. The canister-scoped fixtures do carry them in the delegation, which is why that path can enforce containment.

Change

SubnetCache entries become CachedSubnet { subnet, ranges_authoritative }:

  • fetch_subnet_by_id inserts via a new insert_subnet_keys_only — cached by ID, canister_index untouched.
  • get_subnet_by_canister accepts only root-attested entries. This also covers the interleaved case where a subnet is already in canister_index from an earlier authoritative fetch: a later keys-only insert downgrades the entry rather than widening what it can claim, and the next lookup re-fetches through the canister-scoped path.

Subnets fetched by ID keep their node keys cached — the outer certificate does legitimately attest those — and still report their self-declared ranges via Subnet::iter_canister_ranges / contains_canister, now documented as non-authoritative on that path, mirroring the existing note on Subnet::key. Update calls and read_state are unchanged; they re-derive ranges from the delegation on every call.

Tests

Two new tests, both of which fail against the previous behavior (verified by temporarily restoring it):

  • fetch_subnet_by_id_does_not_index_self_declared_ranges — end-to-end against the real mainnet fixture. Asserts the subnet is still cached by ID, that its self-reported range is still visible on the returned Subnet, and that the canister within it does not resolve out of the cache.
  • subnet_cache_only_routes_root_attested_ranges — pins the cache invariant both ways, including that root-attested ranges still resolve, so the cache isn't quietly disabled.

No existing test covered this boundary; all the current range tests are canister-scoped.

cargo test -p ic-agent --lib → 58 passed, 2 ignored (56 before). cargo clippy --all-targets --workspace is clean. The ic-utils canister::tests::simple failure in a full workspace run is pre-existing on main — it wants scripts/download_reftest_assets.sh.

🤖 Generated with Claude Code

`ic-agent` has two paths that fetch subnet info, and they drew canister ranges
from different certificates:

* `fetch_subnet_by_canister` takes them from the NNS-root-signed delegation and
  checks that they contain the canister.
* `fetch_subnet_by_id` took them from the certificate's outer tree, which the
  subnet signs itself, and wrote them into the shared canister index.

Only the first source is authoritative for deciding which subnet may answer for
a given canister, so the second should not have fed that index. Ranges of the
second kind reached `get_subnet_by_canister`, whose `canister_ranges.contains`
guard then re-checked against that same self-attested set.

The ranges cannot simply be read from the delegation on the by-ID path: for a
subnet-scoped `read_state` the delegation attests only `/subnet/<id>/public_key`
and `/time`, as this repo's own mainnet fixture
(`agent_test/uzr34_node_keys.bin`) shows. So instead, tag cached subnets with
the provenance of their ranges: `fetch_subnet_by_id` now inserts keys-only via
`insert_subnet_keys_only`, and only root-attested entries can resolve a canister
to a subnet. That also covers the interleaved case where a subnet is already in
the index from an earlier authoritative fetch -- a later keys-only insert
downgrades the entry rather than widening what it can claim, and the next lookup
re-fetches through the canister-scoped path.

Subnets fetched by ID are still cached by ID for their node keys, which the
outer certificate does legitimately attest, and still report their self-declared
ranges -- now documented as non-authoritative on that path, mirroring the
existing note on `Subnet::key`. Update calls and `read_state` are unchanged;
they re-derive ranges from the delegation on every call.

Adds regression coverage for the cache boundary, which no existing test
exercised -- all of the current range tests are canister-scoped. Both new tests
fail against the previous behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lwshang lwshang changed the title fix(ic-agent): don't route canisters by self-attested subnet ranges fix(ic-agent): only route canisters by root-attested subnet ranges Sep 15, 2026
@lwshang
lwshang force-pushed the fix/subnet-id-range-cache-poisoning branch from 18a8fca to e028a7c Compare September 15, 2026 13:22
@lwshang
lwshang requested a balanced review from Copilot September 15, 2026 14:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The cache provenance guard correctly closes the self-attested routing path and is covered by focused regression tests.

Pull request overview

Restricts canister routing to authoritative, root-attested subnet ranges while retaining subnet-by-ID key caching.

Changes:

  • Tracks range provenance in cached subnet entries.
  • Prevents subnet-ID fetches from populating the canister index.
  • Adds regression tests and provenance documentation.
File summaries
File Description
ic-agent/src/agent/subnet.rs Documents range authority semantics.
ic-agent/src/agent/mod.rs Enforces authoritative cache routing.
ic-agent/src/agent/agent_test.rs Tests cache provenance invariants.
CHANGELOG.md Records the security-related fix.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@lwshang
lwshang marked this pull request as ready for review September 15, 2026 14:58
@lwshang
lwshang requested a review from a team as a code owner September 15, 2026 14:58
@zeropath-ai

zeropath-ai Bot commented Sep 15, 2026

Copy link
Copy Markdown

✅ No security or compliance issues detected. Reviewed everything up to e028a7c.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► ic-agent/src/agent/mod.rs
    Update fetch_subnet_by_canister and fetch_subnet_by_id docs and behavior to distinguish authoritative canister ranges
► ic-agent/src/agent/mod.rs
    Adjust caching strategy to store subnets with provenance flag and to index canister ranges only when authoritative
► ic-agent/src/agent/subnet.rs
    Document provenance of canister ranges and clarify behavior when fetched by ID vs by canister
Enhancement ► ic-agent/src/agent/subnet.rs
    Add documentation clarifying authoritative ranges and fetch paths

@lwshang
lwshang merged commit 8d57920 into main Sep 16, 2026
22 checks passed
@lwshang
lwshang deleted the fix/subnet-id-range-cache-poisoning branch September 16, 2026 08:44
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.

3 participants