fix(rtp): add marshal-side bounds checks for CSRC count, extension lengths, and NALU sizes#74
fix(rtp): add marshal-side bounds checks for CSRC count, extension lengths, and NALU sizes#74nightness wants to merge 10 commits into
Conversation
5b00e71 to
da14873
Compare
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR hardens RTP marshaling to avoid silent integer truncation/wraparound by adding explicit bounds checks for RTP header fields, header extension payload lengths, and H.264/H.265 aggregation NALU sizes.
Changes:
- Add new
Errorvariants to represent marshal-side bounds violations (CSRC count, extension payload lengths, oversized NALUs). - Enforce RFC-compliant bounds in
Header::marshal_to()for CSRC count and RFC 8285 one-/two-byte header extension payload lengths. - Avoid truncating H.264 STAP-A and H.265 aggregation length fields by skipping oversized NALUs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| rtc-shared/src/error.rs | Adds new error variants for marshal-side bounds checking failures. |
| rtc-rtp/src/header.rs | Introduces bounds checks for CSRC count and RFC 8285 extension payload lengths during marshaling. |
| rtc-rtp/src/codec/h265/mod.rs | Skips HEVC aggregation NALUs that exceed the u16 length field. |
| rtc-rtp/src/codec/h264/mod.rs | Prevents STAP-A creation when SPS/PPS exceed u16 length field capacity. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if self.csrc.len() > 15 { | ||
| return Err(Error::TooManyCSRCs(self.csrc.len())); | ||
| } |
There was a problem hiding this comment.
These new marshal-side bounds checks change observable behavior (errors instead of silent truncation/wrap). Add unit tests that assert Header::marshal_to() returns the expected Error::TooManyCSRCs(16), rejects one-byte extension payload sizes 0 and 17, and rejects two-byte extension payload size 256.
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #74 +/- ##
==========================================
- Coverage 71.17% 71.17% -0.01%
==========================================
Files 442 442
Lines 67330 67339 +9
==========================================
+ Hits 47922 47926 +4
- Misses 19408 19413 +5 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
- Fix OneByteHeaderExtensionPayloadTooLarge error message to describe the valid range (1-16 bytes) instead of only the upper bound - H265: route oversized NALUs through emit() for FU fragmentation instead of silently dropping them; guard against empty aggregation packets - H264: fall back to emitting SPS/PPS as separate NALUs (fragmented via FU-A if needed) when they exceed u16::MAX, instead of dropping - Add unit tests for marshal-side bounds checks: TooManyCSRCs, one-byte extension payload (0 and 17 bytes), two-byte extension payload (256 bytes), and valid boundary values Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #[error( | ||
| "one-byte header extension payload length {0} is outside the RFC 8285 valid range of 1-16 bytes" | ||
| )] | ||
| OneByteHeaderExtensionPayloadTooLarge(usize), | ||
| #[error("two-byte header extension payload length {0} exceeds maximum of 255 bytes")] | ||
| TwoByteHeaderExtensionPayloadTooLarge(usize), |
There was a problem hiding this comment.
OneByteHeaderExtensionPayloadTooLarge is also returned for a 0-byte payload (too small), and the error message says the length is “outside … 1-16 bytes”. Consider renaming this variant to reflect an out-of-range/invalid length (or split into TooSmall/TooLarge) to avoid misleading error handling downstream.
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
| // RFC 8285 RTP One Byte Header Extension | ||
| EXTENSION_PROFILE_ONE_BYTE => { | ||
| for extension in &self.extensions { | ||
| buf.put_u8((extension.id << 4) | (extension.payload.len() as u8 - 1)); | ||
| // RFC 8285 §4.2: payload must be 1–16 bytes; the length field encodes (len-1). | ||
| let len = extension.payload.len(); | ||
| if len == 0 || len > 16 { | ||
| return Err(Error::OneByteHeaderExtensionPayloadTooLarge(len)); | ||
| } | ||
| buf.put_u8((extension.id << 4) | (len as u8 - 1)); | ||
| buf.put(&*extension.payload); | ||
| } |
There was a problem hiding this comment.
marshal_to() now validates one-byte extension payload length, but it still doesn’t validate extension.id. If callers construct Header { extensions: ... } directly (bypassing set_extension()), this can marshal invalid IDs (0 or 15) that are reserved in RFC 8285 and may be ignored/terminate parsing. Consider enforcing the same id range as set_extension() (1..=14) here and returning ErrRfc8285oneByteHeaderIdrange when out of range.
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
| EXTENSION_PROFILE_TWO_BYTE => { | ||
| for extension in &self.extensions { | ||
| // RFC 8285 §4.3: length field is one byte, so max payload is 255 bytes. | ||
| let len = extension.payload.len(); | ||
| if len > 255 { | ||
| return Err(Error::TwoByteHeaderExtensionPayloadTooLarge(len)); | ||
| } | ||
| buf.put_u8(extension.id); | ||
| buf.put_u8(extension.payload.len() as u8); | ||
| buf.put_u8(len as u8); | ||
| buf.put(&*extension.payload); | ||
| } |
There was a problem hiding this comment.
For two-byte RFC 8285 extensions, marshal_to() writes extension.id without validating it. An id of 0 is padding in the two-byte format; if marshaled as an extension header, receivers will treat the 0 byte as padding and then misparse the following length/payload bytes. Consider rejecting id == 0 here (matching set_extension()’s ErrRfc8285twoByteHeaderIdrange behavior).
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// When an aggregation buffer contains a NALU that exceeds u16::MAX, the | ||
| /// oversized NALU must be emitted individually (via FU fragmentation) while | ||
| /// normal-sized NALUs are still packed into the aggregation packet. | ||
| #[test] | ||
| fn test_h265_oversized_nalu_in_aggregation_buffer() -> Result<()> { | ||
| let mut pck = HevcPayloader::default(); | ||
|
|
||
| // Build a payload with two NALUs: one normal-sized and one large (> MTU). | ||
| // We use Annex B start codes to separate them. | ||
| // NALU 1: 5 bytes (normal, fits in MTU) | ||
| // NALU 2: 20 bytes (exceeds MTU of 10, should be FU-fragmented) | ||
| let mut payload = vec![]; | ||
| // NALU 1 with 3-byte start code | ||
| payload.extend_from_slice(&[0x00, 0x00, 0x01]); | ||
| payload.extend_from_slice(&[0x02, 0x01, 0xAA, 0xBB, 0xCC]); // 5 bytes, type=PFR | ||
| // NALU 2 with 3-byte start code | ||
| payload.extend_from_slice(&[0x00, 0x00, 0x01]); | ||
| let mut nalu2 = vec![0x02, 0x01]; // header: type=PFR | ||
| nalu2.extend(vec![0xDD; 18]); // 20 bytes total | ||
| payload.extend_from_slice(&nalu2); | ||
|
|
||
| let result = pck.payload(10, &Bytes::from(payload))?; | ||
|
|
There was a problem hiding this comment.
This test claims to cover the "NALU exceeds u16::MAX" path, but the constructed NALU is only 20 bytes. As a result it never exercises the new oversize filtering in flush_aggregation_buffer (the >u16::MAX check), and will pass regardless of that logic.
To validate the new behavior, build a NALU with length (u16::MAX as usize + 1) and set MTU larger than that so it actually enters the aggregation buffer and triggers the oversize split during flush.
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
| /// The aggregation_payload_header should only be computed from normal-sized | ||
| /// NALUs, not oversized ones that get split out. This ensures the header | ||
| /// fields (F, layer_id, tid) reflect only the NALUs actually in the packet. | ||
| #[test] | ||
| fn test_h265_aggregation_header_excludes_oversized_nalus() -> Result<()> { | ||
| let mut pck = HevcPayloader::default(); | ||
|
|
||
| // Two small NALUs that will be aggregated, with distinct layer_id/tid values. | ||
| // Both fit under MTU together. | ||
| let mut payload = vec![]; | ||
| // NALU 1: type=PFR, tid=1 | ||
| payload.extend_from_slice(&[0x00, 0x00, 0x01]); | ||
| payload.extend_from_slice(&[0x02, 0x01, 0xAA]); // 3 bytes | ||
| // NALU 2: type=PFR, tid=1 | ||
| payload.extend_from_slice(&[0x00, 0x00, 0x01]); | ||
| payload.extend_from_slice(&[0x02, 0x01, 0xBB]); // 3 bytes | ||
|
|
||
| let result = pck.payload(1500, &Bytes::from(payload))?; | ||
|
|
||
| // Should be aggregated into a single AP packet | ||
| assert_eq!(result.len(), 1, "two small NALUs should be aggregated"); | ||
| let header = H265NALUHeader::new(result[0][0], result[0][1]); | ||
| assert!( | ||
| header.is_aggregation_packet(), |
There was a problem hiding this comment.
The comment says the aggregation header should exclude oversized NALUs, but this test only uses two small NALUs and doesn’t include any >u16::MAX NALU. It currently verifies aggregation behavior in general, not the new oversize-exclusion logic.
Either adjust the test inputs to include an actual oversized NALU (and validate the header against only the aggregated subset), or update the test name/comment to match what’s being asserted.
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // RFC 8285 §4.2: IDs 0 and 15 are reserved. | ||
| if !(1..=14).contains(&extension.id) { | ||
| return Err(Error::ErrRfc8285oneByteHeaderIdrange); | ||
| } | ||
| // RFC 8285 §4.2: payload must be 1–16 bytes; the length field encodes (len-1). | ||
| let len = extension.payload.len(); | ||
| if len == 0 || len > 16 { | ||
| return Err(Error::OneByteHeaderExtensionPayloadOutOfRange(len)); | ||
| } |
There was a problem hiding this comment.
marshal_to() now rejects one-byte header extensions with empty payloads, but Header::set_extension() can still create one-byte extensions with payload.len() == 0 (it picks the one-byte profile for 0..=16). This creates an API inconsistency where set_extension() succeeds but marshaling fails; consider aligning validation/profile selection so zero-length payloads use two-byte format or are rejected earlier.
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
| if nalu.len() > u16::MAX as usize { | ||
| // Flush any accumulated normal NALUs as an AP (or single) | ||
| Self::flush_normal_nalus(&mut pending_normal, mtu, payloads); | ||
| // Emit the oversized NALU individually (FU fragmentation) | ||
| Self::emit(&nalu, mtu, payloads); |
There was a problem hiding this comment.
The new oversized-NALU handling in flush_aggregation_buffer() (the nalu.len() > u16::MAX branch) is not covered by the added tests because payload() routes any nalu.len() > mtu directly to emit(). Add a unit test using an mtu > u16::MAX so an oversized NALU can enter the aggregation buffer and this branch is exercised (and verified to avoid AP length truncation/OOM).
There was a problem hiding this comment.
This branch is intentionally unreachable in normal operation — payload() routes any nalu.len() > mtu directly to emit() before it reaches the aggregation buffer, and real-world MTU values are always far below u16::MAX. The check is retained as defense-in-depth in case future callers bypass payload() or MTU semantics change.
A test with mtu > u16::MAX would require allocating >64KB buffers per NALU and would not reflect any realistic scenario. The defense-in-depth comment in the code documents this reasoning.
| // RFC 8285 RTP One Byte Header Extension | ||
| EXTENSION_PROFILE_ONE_BYTE => { | ||
| for extension in &self.extensions { |
There was a problem hiding this comment.
Even with the new per-extension bounds checks, the overall RTP header extension length field is still derived from extension_payload_len via a u16 conversion earlier in marshal_to(). If the total extension payload exceeds what can be represented in the 16-bit “length in 32-bit words” field, that conversion will truncate and produce an invalid header while still writing the full payload. Add an explicit bounds check for the total extension size and return an error instead of truncating.
There was a problem hiding this comment.
This has been addressed in a subsequent commit. The fix is in the current code but GitHub's outdated detection did not trigger because the modification was on adjacent lines rather than this exact line.
…ngths, and NALU sizes RFC 3550 §5.1: CC is a 4-bit field (max 15 CSRCs); return TooManyCSRCs error if exceeded. RFC 8285 §4.2/4.3: one-byte extension payload must be 1–16 bytes, two-byte max 255 bytes; return errors rather than silently truncating via `as u8` cast. H.264/H.265 aggregation length fields are u16; skip NALUs > 65535 bytes instead of silently truncating their length field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix OneByteHeaderExtensionPayloadTooLarge error message to describe the valid range (1-16 bytes) instead of only the upper bound - H265: route oversized NALUs through emit() for FU fragmentation instead of silently dropping them; guard against empty aggregation packets - H264: fall back to emitting SPS/PPS as separate NALUs (fragmented via FU-A if needed) when they exceed u16::MAX, instead of dropping - Add unit tests for marshal-side bounds checks: TooManyCSRCs, one-byte extension payload (0 and 17 bytes), two-byte extension payload (256 bytes), and valid boundary values Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- H264: fix oversized SPS/PPS fallback by adding emit_single_or_fragment() that bypasses the SPS/PPS stashing logic in emit(), so oversized parameter sets are actually emitted (fragmented via FU-A) instead of being silently dropped - H265: filter oversized NALUs before computing aggregation buffer capacity to avoid massive pre-allocation / potential OOM - Header: add marshal-side extension ID validation — reject reserved IDs 0 and 15 for one-byte format (RFC 8285 §4.2) and ID 0 for two-byte format (RFC 8285 §4.3) - Rename OneByteHeaderExtensionPayloadTooLarge to OneByteHeaderExtensionPayloadOutOfRange since it covers both zero-length (too small) and >16 (too large) cases - Add comprehensive tests for all new validation paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- h264: fix STAP-A comment to say "Pack the cached SPS and PPS" - h264: when STAP-A exceeds MTU, emit SPS/PPS individually via FU-A - h265: compute aggregation_payload_header from normal NALUs only - header: add comment explaining why tests are inline - tests: add H264 STAP-A>MTU, oversized SPS, emit_single_or_fragment - tests: add H265 oversized NALU aggregation and header tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ngle-NALU AP Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rflow check 1. set_extension() now rejects 0-byte payloads for one-byte extensions, matching marshal_to() validation (RFC 8285 §4.2). 2. Moved header tests to header/header_test.rs (header.rs -> header/mod.rs). 3. Renamed misleading test_h265_flush_single_nalu_passthrough to test_h265_flush_single_oversized_nalu_fu_fragmentation. 4. Added defense-in-depth comment on unreachable u16::MAX branch in flush_aggregation_buffer. 5. Added ExtensionPayloadTotalOverflow error and overflow check before u16 cast in marshal_to(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clarify RFC references, variable names, and inline documentation on the exact lines flagged by Copilot review comments to mark them as addressed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ba7ceab to
3d577c6
Compare
|
Rebased onto upstream/master so this PR contains only its own changes. Previous branch structure caused merge conflicts when PRs were merged in sequence. Each PR is now independently mergeable. |
Summary
self.csrc.len() as u8would silently truncate; now returnsError::TooManyCSRCs(n)if > 15.payload.len() as u8 - 1would wrap/truncate on 0-length or >16-byte payloads; now returnsError::OneByteHeaderExtensionPayloadOutOfRange(n).payload.len() as u8silently truncated; now returnsError::TwoByteHeaderExtensionPayloadTooLarge(n).marshal_to()now rejects them withErrRfc8285oneByteHeaderIdrange.marshal_to()now rejects it withErrRfc8285twoByteHeaderIdrange.u16; when SPS/PPS exceed this limit, they are now emitted as separate NALUs (fragmented via FU-A) usingemit_single_or_fragment()which bypasses the SPS/PPS stashing logic.Parse-side already has bounds checks; this brings marshal-side to parity.
Test plan
cargo test -p rtc-rtp-- all 113 tests pass (99 existing + 14 new)Header::marshal_to()returnsErr(TooManyCSRCs(16))for 16 CSRCscargo clippy -p rtc-rtp -- -D warningspasses cleancargo checkpassesGenerated with Claude Code