fix: saturate Fin sequence arithmetic in proposal-part streaming - #195
fix: saturate Fin sequence arithmetic in proposal-part streaming#195memosr wants to merge 2 commits into
Conversation
|
Nice fix — the On the README "Networks" section: can independently corroborate both corrections from our own testnet integration —
Good to see these codified in the README directly rather than left to circulate informally — the |
A Fin stream message carries an unvalidated u64 `sequence` straight off the wire. `StreamState::insert` computed the stream's expected message count as `msg.sequence as usize + 1`, which a malicious peer could drive to overflow by sending `sequence = u64::MAX`: a panic under debug-assertions and a wrap-to-zero in release builds. Release builds happened to stay safe because the subsequent `buffer.len() == expected_messages` check never matches a wrapped-to-zero target, so the stream is left incomplete and later evicted. But the guarantee rested on wrapping behaviour rather than intent, and the accompanying comment justified the arithmetic with a false invariant: `expected_messages` is assigned from the unbounded `msg.sequence`, not from the bounded `message_count`. Use `saturating_add(1)` so the worst case is `usize::MAX` — an unreachable completion target the stream is evicted for — instead of a panic or a silent wrap. Comment corrected to describe the real bound. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f5fa39c to
4fd0e5a
Compare
|
Update: the three README commits that were originally in this branch have been |
There was a problem hiding this comment.
Disclosure: I'm not affiliated with Circle — an external community contributor, not a maintainer. I have no write access to this repository, so any review state I set (approval or change request) carries no merge authority and is advisory only. Please treat this as one contributor's technical assessment, and defer to Circle maintainers for the binding review.
Confirmed the split: this branch is now a single commit touching only crates/malachite-app/src/streaming.rs, and #263 carries the README material — reviewing each on its own merits is now possible, which is what the security fix deserved.
Re-verified the premise against a fresh clone of main (static verification on my side — no Rust toolchain here, so your 46-test run and CI are the executable check):
streaming.rs:287still readsself.expected_messages = msg.sequence as usize + 1;under the comment asserting "+1 cannot overflow because MAX_MESSAGES_PER_STREAM << u64::MAX" — and your diagnosis of that comment is exactly right: the bound applies tomessage_count(checked at line 260), whileexpected_messagesis assigned from the unbounded wire value. Two different quantities, one invariant claimed for both.- The "release builds are incidentally safe" reasoning also holds up: after a Fin insert the buffer holds at least that one message, so
buffer.len() == 0can never match and the wrapped stream parks asIncompleteuntil eviction. Correctly scoping this as debug/test-panic + latent-reliance rather than a live release crash is the kind of severity honesty that makes a security PR easy to trust. saturating_add(1)is the right primitive:usize::MAXis an unreachable completion target given theMAX_MESSAGES_PER_STREAMcap on inserts, so the malicious stream dies by eviction on both build profiles. And dropping thearithmetic_side_effectsallow while keepingcast_possible_truncationis the correct split — the former is now genuinely handled, the latter is still real.
One residual nuance worth a line in the comment, non-blocking: the retained truncation allow means on a 32-bit target msg.sequence as usize truncates before the saturating add — e.g. 2^32 + 5 becomes 5, so a malicious Fin could set a small-but-wrong completion target rather than an unreachable one. On 64-bit targets (every realistic node deployment, and what the old comment already assumed) this is moot, but since the new comment's theme is "this field is peer-controlled and unbounded," it might as well state the 64-bit assumption explicitly instead of inheriting it silently from the allow.
One suggestion that would strengthen the PR at near-zero cost: the test plan runs the existing suite, but nothing pins the new behavior. A regression test that inserts a Fin with sequence = u64::MAX under debug assertions — asserting no panic and that the stream remains incomplete — would (a) fail on today's main, proving the fix, and (b) stop a future refactor from quietly reintroducing the + 1. Given the property tests already in the module, it should slot in as a few lines.
Fix is correct as-is; both notes are hardening, not blockers.
…-bit cast assumption
|
Both notes applied, thanks. 64-bit assumption: the comment now states it explicitly instead of leaving it Regression test: added Verified it behaves as a regression test should. With the fix, 47/47 streaming So it fails on pre-fix code and passes after, which is what pins the behavior. |
There was a problem hiding this comment.
Disclosure: I'm not affiliated with Circle — an external community contributor, not a maintainer. I have no write access to this repository, so any review state I set (approval or change request) carries no merge authority and is advisory only. Please treat this as one contributor's technical assessment, and defer to Circle maintainers for the binding review.
Reviewed 62df0de — both notes are closed properly, and the way they were closed is worth remarking on:
The 64-bit comment now does more than state the assumption: it describes the exact 32-bit failure mode (truncation before the saturating add → small-but-wrong completion target) and its bounded consequence (still evicted, just wrong). That's the right level of detail — a future reader porting this to a 32-bit target gets the full threat analysis at the site of the cast, not just a warning flag. The old comment asserted a false invariant; the new one documents a true, explicitly-scoped one.
The regression test is textbook. Three properties asserted — no panic, no completion, stream stays pending eviction — which pin both build-profile failure modes at once: the debug panic (the insert itself) and the release wrap-to-zero (completion would have required matching a wrapped target). And your revert-and-rerun proof is the part I'd highlight for the maintainers: reverting only the saturating_add back to + 1 on the same tree makes the new test fail with attempt to add with overflow at exactly the fixed line, then 47/47 pass with the fix. A regression test that has been demonstrated to fail on pre-fix code is categorically stronger evidence than one that merely passes post-fix — it can't be vacuous.
Static verification on my side as before (the diff structure, assertion logic, and test-helper usage all check out against the module's existing patterns; your executed runs and CI are the ground truth). Nothing further from me — the fix was already correct, and now it's also self-defending: the false invariant is replaced by a documented true one, and the arithmetic is pinned by a test that fails loudly if anyone walks it back. Ready for a maintainer, and between this and #263 the split has served both halves well.
|
Re-verified this against current
self.expected_messages = msg.sequence as usize + 1;
For precedent, Happy to adjust the approach if you would rather this saturate elsewhere or return an error instead. |
|
Independently re-verified everything in your last comment against
On your open question ("saturate elsewhere or return an error instead") — I'd keep That said, there's a strictly-stronger optional hardening worth considering as a follow-up (not a blocker for this PR): an explicit upper-bound reject on the Fin sequence, mirroring the existing if msg.sequence >= MAX_MESSAGES_PER_STREAM as u64 {
return StreamInsertResult::ExceededMaxMessages; // or a dedicated variant
}A legitimate Approving as-is. |
Summary
StreamState::insertin the consensus proposal-part streaming layercomputed a stream's expected message count from the Fin message's
sequencefield asmsg.sequence as usize + 1.sequenceis anunvalidated
u64decoded straight off the gossip wire(
crates/types/src/codec/proto.rs—sequence: proto.sequence, nobound-check), and
PartStreamsMap::insertapplies no upper bound on itbefore reaching this arithmetic. A peer on the consensus gossip topic can
therefore send a Fin part for the current height with
sequence = u64::MAXand drive the+ 1to overflow:0.Release builds are incidentally safe today: the later
buffer.len() == expected_messagescompletion check never matches awrapped-to-zero target, so the stream stays
Incompleteand is evictedby the age/limit sweep. No crash, no unbounded memory (already capped by
MAX_MESSAGES_PER_STREAM). The safety rested on wrapping behaviourrather than intent, and the old comment asserted a false invariant — it
claimed the
+ 1"cannot overflow because MAX_MESSAGES_PER_STREAM <<u64::MAX", but
expected_messagesis assigned from the unboundedmsg.sequence, not from the boundedmessage_count.Change
saturating_add(1): worst case isusize::MAX, an unreachablecompletion target the stream is evicted for — never a panic or a silent
wrap-to-zero.
source of
sequence.clippy::arithmetic_side_effectsallow, sincethe arithmetic is checked.
Impact
Eliminates a remotely reachable panic in debug/test builds and removes
release builds' latent reliance on wrap-to-zero. No behavioural change on
valid streams (
sequencevalues are< MAX_MESSAGES_PER_STREAMinpractice). No public API or wire-format change; the touched method is
module-private and
PartStreamsMap::insert's signature is unchanged.Test plan
cargo build -p arc-node-consensus— cleancargo test -p arc-node-consensus streaming— 46 passed, 0 failed(includes property tests for per-stream / total stream limits),
run under debug-assertions
🤖 Generated with Claude Code