fix(holdings): refuse a peer-declared address count before reserving on it - #122
Merged
Conversation
Measures the peak single allocation made while decoding an opcode-222 frame that declares 65,535 addresses and carries none. Fails today at 2,097,120 bytes: the peer-declared u16 sizes a Vec reservation before the signature is checked. Asserted on the allocation rather than the return value on purpose. The fix is a placement, and a decoder that reserves first then fails on the truncated address bytes returns the same None as one that rejects the count up front, so an is_none() assertion cannot discriminate the two. Refs #1723 Co-Authored-By: Claude <noreply@anthropic.com>
…is refused before reserving dig-node pinned dig-gossip v0.17.12, which reserves a Vec from the per-change address count an opcode-222 frame declares. decode runs before verify_holdings_announce, so an unauthenticated peer could commit ~2 MiB with a ~200-byte frame. v0.17.14 clamps the count against MAX_ADDRS_PER_CHANGE (32) before reserving; this adopts v0.17.21, the current release carrying it. The three vendored-fork pins move together to the same rev, so the #1529 lockstep between dig-gossip and its chia-protocol/chia-sdk-client forks holds again rather than straddling two revs. v0.17.15 added PoolRemovalReason::Reaped, so the gossip -> selector removal map gains it. It folds to the non-punitive Disconnected: the selector distinguishes only Banned behaviourally, so the fold is observability-only, and folding a provably-departed peer to Banned would bias the node toward unremembered peers. Refs #1723 Co-Authored-By: Claude <noreply@anthropic.com>
…own ingress gate The ingress ladder began at verify_holdings_announce, but the count checks necessarily run BEFORE it — decode precedes the signature check — so an allocation sized from a declared count is one an unauthenticated peer commissioned. Stated as gate 0 with the general rule it instantiates. Bumps the released binary to 0.68.1 and dig-node-core to 0.25.1: a compatible fix, no API removed and no wire shape changed. No legitimate frame is affected, since dig-gossip has capped both counts at the sender since v0.17.13. Refs #1723 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
marked this pull request as ready for review
July 31, 2026 13:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
#1723— the opcode-222 decode path sized aVecreservation from the per-change address count the frame declares. Decode runs beforeverify_holdings_announce, so an unauthenticated peer could commission a ~2 MiB reservation with a ~200-byte frame.Verdict on the ticket's open question: it is a real defect, not an accepted cost. The
u16does bound it, but the entry isCandidateAddr { host: String, port: u16 }at 32 bytes, so the ceiling is 65,535 × 32 = 2,097,120 bytes — measured, not estimated (see below). Upstream reached the same conclusion independently and clamped it in dig-gossip v0.17.14 (c4630ff, filed there as #1777, a duplicate of #1723). dig-node pinned v0.17.12 and therefore still carried the defect at v0.68.0.Since the decoder lives in dig-gossip, dig-node cannot host the guard. dig-node's coherent fix is to adopt the release carrying it, and to pin the property with a test so a future rev cannot silently regress it.
Blast radius checked
gitnexus is disabled in the loop (CLAUDE.md §2.0 temp override), so this was done with ripgrep + a direct read at
origin/main.dig_gossip::HoldingsAnnounce::decodedig_gossip::holdings_announce_payload, called atholdings.rs:960. No other dig-node path decodes opcode 222.download::pool_removal_reasonpeer.rs:1924boundary conversion) + 1 test.pub(crate), so the radius is the crate.download::GossipRemovalReasonpub(crate), constructed only at that one boundary. Adding a variant reaches only the two match sites, both updated.Nothing on a published API surface changes. Risk: LOW — no HIGH/CRITICAL finding to warn on.
The dependency move
dig-gossipv0.17.12 → v0.17.21 (dd29ce8d). All three pin sites move to the same rev — thedig-gossipdep plus thechia-protocol/chia-sdk-clientvendored-fork[patch.crates-io]entries — so the #1529 lockstep holds again instead of straddling two revs.cargo update -p dig-gossipconfirms one instance of each resolves.The v0.17.12..v0.17.21 delta is 5 fixes + 3 docs commits. One of them is source-breaking here: v0.17.15 added
PoolRemovalReason::Reaped, which failed the build as a non-exhaustive match.Reapedfolds toDisconnected, deliberatelyThe selector has no
Reaped, so it must fold into one of its three. It folds toDisconnectedbecause a reaped peer's transport was provably closed — a departure — whereasDeadnames a keepalive finding a peer unresponsive.What makes that safe rather than merely tidy: the selector distinguishes only
Bannedbehaviourally (dig-peer-selector-0.7.0/src/engine.rs:467matches on it alone), treatingDisconnectedandDeadidentically. So the fold is observability-only and cannot change eligibility. Folding it toBannedwould have been the real error — it would make an honestly-departed peer ineligible and bias the node toward unremembered peers, which is a sybil. The mapping test now pins the fold so a later edit cannot quietly turn it into that.How it was verified
The test asserts the ALLOCATION, because the fix is a PLACEMENT
A decoder that reserves first and then fails on the truncated address bytes returns exactly the same
Noneas one that rejects the count up front.assert!(decoded.is_none())therefore passes against the defect and against the fix — it would pin a coincidence, and a later refactor moving the guard back below the reservation would keep it green. This was not theoretical: in the red run below, theis_none()assertion passed and only the allocation assertion failed.So
crates/dig-node-core/tests/holdings_decode_alloc.rsinstalls a#[global_allocator]that records the peak single allocation request on the calling thread (thread-local, so concurrent tests in the binary cannot perturb it), and measures it across one call toholdings_announce_payload— dig-node's real inbound seam, not a helper.The frame is truncated immediately after the declared count. That is what makes it discriminating: with no address bytes present, the only reason a large allocation could appear is the declaration being trusted.
The ceiling (64 KiB) is sized from the protocol, per the ticket's own instruction not to pick a number below the bound: the honest worst case is
MAX_ADDRS_PER_CHANGE(32) × 32 bytes, under a kibibyte, and 64 KiB sits ~32× below the ~2 MiB a trustedu16commits — so the assertion has room for the header's legitimate small allocations while leaving no room for the defect.Red → green, with only the dep bump between
1894f7e, test alone)requested 2097120 bytes in a single allocation (ceiling 65536). Exactly the ~2 MiB the ticket predicted.0f5bafa, bump applied)The test was committed before the fix, so the transition is recorded in history rather than reconstructed — and the only change between the two runs is the dependency rev, which is the isolated-revert proof in the correct order.
Gates
cargo test --locked— 1193 passed, 0 failed, exit 0. No flakes hit (module_serve::…window_capand the link.exe 1318 issue both stayed clear).cargo clippy --all-targets --locked -- -D warnings— clean.cargo fmt --all --check— clean.cargo build --locked— realCompiling dig-node-core v0.25.1line confirmed, so this is not a stale-fingerprint pass.Version
0.68.0→0.68.1(+dig-node-core0.25.0→0.25.1). Patch: a compatible security fix. No API removed or renamed, no wire shape changed, and no legitimate frame's treatment changes — dig-gossip has capped both counts at the sender since v0.17.13, so only a crafted frame is affected.#[non_exhaustive]-style breakage does not apply; the extended enum ispub(crate).SPEC
SPEC.md§19.3a gains the decode-time bound as gate 0 of the ingress ladder. It is stated separately from gate 1 rather than folded into it because it necessarily runs before the signature check — which is precisely why it matters — along with the general rule it instantiates: never size an allocation from a number a peer supplied.Deliberately not in this PR
origin/main(see the issue comment); the unlanded half isdig-download's paged-prologue reassembly, a different repo. Nothing owed here.SYSTEM.mdneeds nothing: the contract is unchanged, only the pinned rev enforcing it.Refs #1723