Repository navigation
fix(proto): bound assembler chunk count (RUSTSEC-2026-0185) (1.3) - #26
Conversation
Port of quinn-rs/quinn@fed0321a (quinn-rs/quinn#2694, RUSTSEC-2026-0185): a peer sending small gapped STREAM or CRYPTO frames could make the assembler buffer an unbounded number of chunks. After defragmenting, more than 1024 chunks now closes the connection with INTERNAL_ERROR. Co-Authored-By: Luke Curley <kixelated@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port of quinn-rs/quinn@4b88c940 (quinn-rs/quinn#2789). The cap above was only checked inside the over-allocation branch, so gapped frames whose allocation matches their length, or frames handled entirely by the unordered dedup loop, still grew the chunk count without bound. Compact once the count passes twice the cap and reject only if compaction cannot bring it back under. Co-Authored-By: Luke Curley <kixelated@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port of quinn-rs/quinn@c3f8b098 (quinn-rs/quinn#2814). defragment now combines contiguous chunks smaller than buffered / MAX_CHUNKS, so a large stream_receive_window with a slow in-order reader no longer trips TooManyChunks on lossless data. Chunks above the minimum size are left alone so they are not copied on every compaction. Co-Authored-By: Luke Curley <kixelated@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Findings
High: count-triggered compaction drops unread data in unordered mode (noq-proto/src/connection/assembler.rs:117, :129, trigger at :221)
Same issue as #25, since this is a faithful backport: defragment trims everything below self.bytes_read, which in unordered mode is a byte count rather than an offset. The new self.data.len() > COMPACT_THRESHOLD trigger makes it reachable on ordinary reordered traffic: an unordered reader (including read_to_end) that has consumed later data first loses earlier buffered chunks on compaction, and the stream never reaches bytes_read() == end. See the #25 review for the reproducing test and the verified fix (if self.state.is_ordered() { self.bytes_read } else { 0 } for both cursors, plus dropping the ordered-only Op::Defragment guard at assembler.rs:974 in the proptest). Whatever lands on #25 should be cherry-picked here.
Checked, no issues
- Faithful backport:
git range-diffshows all three commits identical to #25 (=), andnoq-proto/src/connection/onrelease/1.3matchesmain, so the result is byte-identical to #25's files. - Same checks as #25: bound on both STREAM and CRYPTO paths with
INTERNAL_ERROR, cap checked after compaction, duplicate and overlap floods covered, tests fail without the compaction trigger.
(Written by Claude Opus 5.5)
In unordered mode bytes_read counts the bytes returned, not a stream offset, so starting defragment's cursor there dropped buffered chunks below it. recvd already marked them received, so they were never re-buffered and read_to_end never finished. The count-triggered compaction makes this reachable on ordinary reordered traffic. Start at bytes_read only in ordered mode, and let the property test defragment in unordered mode too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressed the High finding by cherry-picking #25's fix (9c84850) as 609bba0: (Written by Claude Opus 5.5) |
kixelated
left a comment
There was a problem hiding this comment.
Re-review of the unordered-compaction fix (609bba0, release/1.3). The fix commit diff is identical to #25's 9c84850, and noq-proto/src/connection/ is byte-identical to #25's head. I ran the suite on this branch: 29/29 assembler tests pass, including no_duplicate_after_mode_switch, unordered_compaction_keeps_unread_data and the proptest. The analysis below applies unchanged.
No blocking findings. One low-severity note below.
What I checked
- The fix matches the suggestion.
assembler.rs:119setsstart = if self.state.is_ordered() { self.bytes_read } else { 0 }, and both cursors (:124,:136) use it. Starting at 0 in unordered mode is safe.insertdedups againstrecvd(:195), and the mode switch dedups throughdefragmentbefore buildingrecvd(:53), so buffered unordered chunks never overlap. Read chunks are already popped from the heap, so trimming from 0 has nothing to drop and nothing extra to retain. - The regression test proves the bug. I put
start = self.bytes_readback and kept the tests.unordered_compaction_keeps_unread_data(:792) andproptests::assembler_matches_referenceboth fail. The proptest shrinks to a minimal failing input. With the fix, both pass. - The property test now covers unordered compaction.
Op::Defragment => asm.defragment()(:999) has nois_orderedguard. The test catches the reverted fix, so it does reach unordered defragment with unread data. It also passes atPROPTEST_CASES=20000. - The assembler suite passes: 29/29, including
no_duplicate_after_mode_switch,bounded_chunks_*,ordered_lossless_stream_is_not_rejectedanddefrag_respects_max_chunks_at_rounding_boundary. - The RUSTSEC bound still holds. The
data.len() > MAX_CHUNKScheck still runs right afterdefragment()(:231) in both modes. STREAM (streams/recv.rs:84) and CRYPTO (mod.rs:4125) still mapTooManyChunksto a connection error. - Legitimate traffic stays under the cap. I ran a throwaway simulation that was not committed. It sends in-order frames in a 1.25 MB window, with random loss, retransmission one RTT later and jitter. Each case ran with 5 to 10 seeds in both ordered and unordered mode, using 1200, 300 and 100-byte frames at 1% to 40% loss. Readers either drained on every arrival, drained every N arrivals, or stalled. Results:
- With 1200-byte frames, the window holds at most ~1041 chunks, so the cap can never trip.
- With readers that drain promptly, unordered chunk counts stay tiny (at most
read_every). - With stalled readers, no case tripped in either mode.
Low: retransmits on unordered streams can trip the cap under extreme loss
assembler.rs:231. In unordered mode, a retransmit that arrives after the app has read the data on both sides of the hole lands as an isolated chunk. Compaction cannot merge it with anything. In ordered mode the same retransmit would merge with the data after it.
Failure scenario: an unordered stream receives 100-byte frames (for example many streams multiplexed per packet) in a 1.25 MB window at 40% loss, and the reader drains only every ~9000 arrivals. That tripped TooManyChunks in 2 of 10 seeds. Ordered mode tripped in 0 of 10 under the same conditions. Every other combination tripped 0 of 10 in both modes: loss of 2/5/10/20%, and drain intervals of 3000 and 20000 at 40% loss.
This comes from the cap itself, not from this fix. The previous start = bytes_read behaviour avoided it only by silently discarding those chunks. I don't think it needs action here. If it ever shows up in practice, the knob is MAX_CHUNKS, not the cursor.
(Written by Claude Opus 5.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backport of #25 to
release/1.3, for a 1.3.x that moqmaincan pin (it is on 1.3.2; 2.x is on moqdev).Carries quinn's fix for RUSTSEC-2026-0185: the
Assemblerbounds buffered chunks at 1024 after compaction and closes the connection withINTERNAL_ERRORpast it, matching quinn-proto 0.11.18 (quinn-rs/quinn#2694, #2789, #2814). See #25 for details.Public API: none. Wire: a peer that exceeds the chunk cap is closed with
INTERNAL_ERROR. Upstream: n0-computer#828.Clean cherry-pick of #25's four commits, including its fix keeping unread data when compacting an unordered stream;
cargo test -p moq-noq-protoand clippy pass on this branch.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code