Skip to content

fix(proto): bound assembler chunk count (RUSTSEC-2026-0185) (1.3) - #26

Merged
kixelated merged 4 commits into
release/1.3from
fix/assembler-chunk-cap-1.3
Oct 2, 2026
Merged

kixelated merged 4 commits into
release/1.3from
fix/assembler-chunk-cap-1.3

Conversation

@kixelated

@kixelated kixelated commented Sep 30, 2026 •

Copy link
Copy Markdown

Backport of #25 to release/1.3, for a 1.3.x that moq main can pin (it is on 1.3.2; 2.x is on moq dev).

Carries quinn's fix for RUSTSEC-2026-0185: the Assembler bounds buffered chunks at 1024 after compaction and closes the connection with INTERNAL_ERROR past 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-proto and clippy pass on this branch.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

djc and others added 3 commits September 30, 2026 09:45
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

Copy link
Copy Markdown
Author

Decisions (from the maintainer, via the coordinating agent):

Status: #22 is rebuilt on #25 and its changelog updated. #21 is not updated yet; see the comment there.

(Written by Claude Opus 5.5)

@kixelated kixelated mentioned this pull request Sep 30, 2026
@kixelated
kixelated marked this pull request as ready for review October 1, 2026 23:43

@kixelated kixelated left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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-diff shows all three commits identical to #25 (=), and noq-proto/src/connection/ on release/1.3 matches main, 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>
@kixelated

Copy link
Copy Markdown
Author

Addressed the High finding by cherry-picking #25's fix (9c84850) as 609bba0: defragment starts both cursors at bytes_read only in ordered mode, the unordered regression test is added, and the property test now defragments in unordered mode too. noq-proto/src/connection/ is byte-identical to #25's head. cargo test -p moq-noq-proto (489 passed) and clippy pass on this branch.

(Written by Claude Opus 5.5)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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:119 sets start = 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. insert dedups against recvd (:195), and the mode switch dedups through defragment before building recvd (: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_read back and kept the tests. unordered_compaction_keeps_unread_data (:792) and proptests::assembler_matches_reference both 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 no is_ordered guard. The test catches the reverted fix, so it does reach unordered defragment with unread data. It also passes at PROPTEST_CASES=20000.
  • The assembler suite passes: 29/29, including no_duplicate_after_mode_switch, bounded_chunks_*, ordered_lossless_stream_is_not_rejected and defrag_respects_max_chunks_at_rounding_boundary.
  • The RUSTSEC bound still holds. The data.len() > MAX_CHUNKS check still runs right after defragment() (:231) in both modes. STREAM (streams/recv.rs:84) and CRYPTO (mod.rs:4125) still map TooManyChunks to 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)

@kixelated
kixelated merged commit 3a171a3 into release/1.3 Oct 2, 2026
2 checks passed
kixelated added a commit that referenced this pull request Oct 2, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants