Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [lite, announce, ietf, varint, path, pattern, packet, params, streamid, streams, frame, connection]
target: [lite, announce, ietf, varint, path, pattern, ingest, request, packet, params, streamid, streams, frame, connection]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
2 changes: 1 addition & 1 deletion quest/m2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ lite-07; bitrate enforcement is not part of this release.

- [Release execution](/quest/m2/release/README.md) - publication, installed-artifact proof, cleanup, and downstream adoption after m1
- [Untrusted media parsers have fuzz targets](/quest/m2/media-fuzz.md) - fMP4, MKV, FLV, TS, Annex-B, catalog, and AMF0 parsing fuzzed with a bounded-work invariant
- [Wire fuzz coverage](/quest/m2/wire-fuzz-coverage.md) - the audited moq-net decoders have targets that assert an invariant, and the unsafe peer input reaches runs under Miri
- [Wire fuzz coverage](/quest/m2/wire-fuzz-coverage.md) - the cmsg walks in moq-sock and moq-uring have invariant fuzz targets, and the unsafe peer input reaches runs under Miri
- [In-memory unsafe runs under Miri](/quest/m2/miri-memory.md) - kio's and moq-net's memory-only unsafe runs under a nightly Miri job
- [VA-API renderer tests](/quest/m2/vaapi-render-tests.md) - the VA-API renderer, decode, and transcode tests run in `just rs gpu` on AMD and Intel and fail instead of skipping, and hosted CI reports them ignored
- [Every opt-in test target has a CI caller](/quest/m2/test-inventory.md) - the merge gate flags ignored tests, fuzz and bench targets, recipes, and scripts no workflow runs
Expand Down
27 changes: 15 additions & 12 deletions quest/m2/wire-fuzz-coverage.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
# [L] The audited moq-net decoders and peer-reachable unsafe have invariant fuzz targets
# [M] Peer-reachable unsafe has invariant fuzz targets and runs under Miri

## Goal

The moq-net decoders the audit found uncovered are fuzzed with an assertion stronger than "it did not panic", and the hand-written unsafe that peer input reaches in `moq-c`, `moq-uring`, and `moq-sock` runs under Miri or a target of its own. Other peer-facing decoders, such as the media importers and RTMP's AMF0, are outside this quest.
The hand-written unsafe that peer input reaches in `moq-c`, `moq-uring`, and `moq-sock` runs under Miri or a fuzz target of its own, asserting more than "it did not panic". Other peer-facing decoders, such as the media importers and RTMP's AMF0, are outside this quest.

`ietf_wire` already covers both SETUP envelopes and the Setup Options a session reads from them. The gaps that remain:

- The incremental frame and group ingest (`lite::subscriber::FrameIngest`, `ietf::subscriber::GroupIngest`/`SubgroupRecv`, the `lite::ietf` state/object/property decoders) has no target.
- Request-stream framing (`Body`, `Framed`, `FollowUp`, `Next`, `whole_message`) is only unit-tested.
- Every seed body is six bytes or less, so no length-prefix or count rejection path is exercised near its cap.
- `rs/moq-c`, `rs/moq-uring`, and `rs/moq-sock` have no fuzz target and no Miri run. Their cmsg walks now bound every length by the buffer, with unit tests, but nothing searches for the next mistake.
The moq-net half is done: the `ingest` and `request` targets cover the incremental group ingest and request-stream framing, and the seeds include prefixes at and past every cap. What remains: `rs/moq-c`, `rs/moq-uring`, and `rs/moq-sock` have no fuzz target and no Miri run. Their cmsg walks bound every length by the buffer, with unit tests, but nothing searches for the next mistake.

## Plan

Decided 2026-10-22 from the security audit, with the maintainer:

- Add one target for a stream of frame headers, records, and payloads through the incremental ingest, asserting the declared sizes and the stream position stay consistent.
- Add a target for request-stream framing.
- Add seeds that approach a limit (`0xffff` and 64 MiB prefixes) so the rejection paths run.
- Add a Miri job for the crates whose unsafe is reachable from a peer, and a target for `moq-sock`'s cmsg walk and `moq-uring`'s `RecvMeta::parse`. Both walks must stop at a malformed length and keep what came before it.
- Add a target for `moq-sock`'s cmsg walk (`cmsg::Iter` and `ControlMetadata::decode` in `rs/moq-sock/src/udp/unix.rs`) and for `moq-uring`'s `RecvMeta::parse` (`rs/moq-uring/src/udp.rs`). Both walks must stop at a malformed length and keep what came before it: build well-formed control messages from the input, append a malformed header and arbitrary bytes, and assert the walk reads the same metadata as the well-formed prefix, and the metadata a reference model expects from it.
- Add a Miri job for the crates whose unsafe is reachable from a peer.
- Wire every target and the Miri run into the nightly gate, and keep the merge-gate replay of committed regressions.

The point is the invariant, not the coverage number: a target that only checks for a panic would miss the bugs this audit is about.

Each crate follows moq-net's shape (`rs/moq-net/fuzz/README.md`): the target body in a hidden `fuzz` module so the crate's own tests replay `fuzz/regressions/` on stable, a one-line shim per target, an `--exclude` in the `no_fuzz` list of `rs/justfile`, and a row in `nightly.yml`'s fuzz matrix. Target names must stay unique across crates.

Ordering:

- `moq-sock`'s `CMsg::decode` still reads `size_of::<T>()` past a short message (only a `debug_assert_eq!` checks it), which [Bound cmsg payload reads](/quest/m2/cmsg-decode-bound.md) fixes. A moq-sock target landing first would fail nightly on that known overread, so land it after, or with, that fix. The moq-uring target has no such dependency.
- [In-memory unsafe under Miri](/quest/m2/miri-memory.md) shares one `just rs miri` recipe and nightly job with this quest: whichever lands first creates it, and the other adds its crates.

Public API: none. Wire: none.

## Related

- [CI cost and latency](/quest/m0/ci/README.md) - where the nightly budget lives
- [Bound cmsg payload reads](/quest/m2/cmsg-decode-bound.md) - fixes the moq-sock overread a cmsg target would hit
- [In-memory unsafe under Miri](/quest/m2/miri-memory.md) - shares the Miri recipe and nightly job
- [Untrusted media parsers have fuzz targets](/quest/m2/media-fuzz.md) - the media parser targets beside these
14 changes: 14 additions & 0 deletions rs/moq-net/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ test = false
doc = false
bench = false

[[bin]]
name = "ingest"
path = "fuzz_targets/ingest.rs"
test = false
doc = false
bench = false

[[bin]]
name = "request"
path = "fuzz_targets/request.rs"
test = false
doc = false
bench = false

[dependencies]
libfuzzer-sys = "0.4"
moq-net = { workspace = true, features = ["fuzz"] }
Expand Down
18 changes: 16 additions & 2 deletions rs/moq-net/fuzz/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# Fuzzing moq-net

Coverage-guided fuzzing of the wire codecs: the moq-lite and moq-transport control
messages, stream and datagram headers, both varint codecs, and `Path`. These are the
messages, stream and datagram headers, group streams through the incremental ingest,
moq-transport request-stream framing, both varint codecs, and `Path`. These are the
bytes a relay parses from an untrusted peer, so they are the surface worth fuzzing.

```bash
cargo install --locked cargo-fuzz
just rs fuzz lite
```

The targets are `lite`, `announce`, `ietf`, `varint`, `path`, and `pattern`. Extra arguments pass through to
The targets are `lite`, `announce`, `ietf`, `varint`, `path`, `pattern`, `ingest`, and `request`. Extra arguments pass through to
libFuzzer, so `just rs fuzz lite -- -max_total_time=300` bounds a run.

The Nightly workflow runs every target for five minutes, resumes from the previous run's
Expand Down Expand Up @@ -56,6 +57,19 @@ Beyond "does not panic":
PATH and AUTHORITY are one-sided.
- `Path::relative` inverts `Path::resolve`, and never produces a reference that walks
above the root.
- A group stream ingests to the same frames and the same ending whether it arrives whole,
a byte at a time, or in chunks the input picks, and never stalls once complete. Every
frame matches its track's timeline, Object IDs only rise, and the frames hold no more
bytes than the stream carried. On a clean end, where no block length is hidden, every
byte is a frame, a header field, or a property block.
- A request-stream message is its type, a two-byte length, and exactly that body; at
most one GOAWAY migrates it. Its body decodes as each message its type names exactly
when the control stream's length-prefixed reading accepts it, as the same message, and
the REQUEST\_UPDATE readers stop where the framing did.

`seeds/` also holds length prefixes at and one past each cap (a moq-lite message's size,
a moq-transport length, a frame, a property or extension block), so every rejection path
starts with an input beside it.

A refused decode is not a finding unless it asks for more bytes despite a complete
body. An encode failure is legitimate: a value can decode at a version that cannot
Expand Down
7 changes: 7 additions & 0 deletions rs/moq-net/fuzz/fuzz_targets/ingest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
moq_net::fuzz::ingest(data);
});
7 changes: 7 additions & 0 deletions rs/moq-net/fuzz/fuzz_targets/request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
moq_net::fuzz::request(data);
});
2 changes: 1 addition & 1 deletion rs/moq-net/fuzz/regressions/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Regressions

Crash inputs the fuzzer found, one file per input, in a directory named after its
target (`lite/`, `ietf/`, `varint/`, `path/`). `fuzz::tests::regressions` replays them
target (`lite/`, `ietf/`, `request/`, and so on). `fuzz::tests::regressions` replays them
on the stable toolchain as part of `just check`, so a bug found by fuzzing stays fixed
without anyone running the fuzzer again.

Expand Down
Binary file not shown.
Binary file not shown.
63 changes: 63 additions & 0 deletions rs/moq-net/src/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
//! Compiled only under `cfg(test)` or the `fuzz` feature, so none of this is part of
//! the published API. See `fuzz/README.md` for the workflow.

mod ingest;
mod request;

pub use ingest::ingest;
pub use request::request;

use bytes::Bytes;

use crate::{
Expand Down Expand Up @@ -93,6 +99,8 @@ pub const TARGETS: &[(&str, Target)] = &[
("varint", varint),
("path", path),
("pattern", pattern),
("ingest", ingest),
("request", request),
];

/// The moq-lite versions a target decodes at, selected by the input's first byte.
Expand Down Expand Up @@ -1478,9 +1486,46 @@ pub fn seeds() -> Vec<Seed> {
});
}

// Length prefixes at and one past each cap, ahead of a body far too short for them, so
// the fuzzer starts beside every rejection path instead of searching for its bound: a
// moq-lite message's size (0xffff for most, 64 MiB for ANNOUNCE_INIT) and a
// moq-transport control message's two-byte length.
for (index, version) in LITE_VERSIONS.iter().enumerate() {
for kind in 0..LITE_KINDS {
for size in limits(&[0xffff, 64 << 20]) {
let mut data = vec![index as u8, kind];
Encoder::new(&mut data, (*version).into()).varint(size).unwrap();
data.extend_from_slice(&[0; 4]);
seeds.push(Seed {
target: "lite",
kind,
data,
});
}
}
}
for index in 0..IETF_VERSIONS.len() as u8 {
for kind in 0..IETF_KINDS {
seeds.push(Seed {
target: "ietf",
kind,
data: vec![index, kind, 0xff, 0xff, 0, 0, 0, 0],
});
}
}

seeds.extend(ingest::seeds());
seeds.extend(ingest::limit_seeds());
seeds.extend(request::seeds());

seeds
}

/// Each of `caps`, and one past it.
pub(crate) fn limits(caps: &[u64]) -> impl Iterator<Item = u64> + '_ {
caps.iter().flat_map(|cap| [*cap, cap + 1])
}

type FrameReader = coding::Reader<crate::transport::poll::Buffered<Chunks>, lite::Version>;

/// Frames arriving on concurrent group streams, as the frame bench times them: one
Expand Down Expand Up @@ -1689,6 +1734,24 @@ mod tests {
assert!(missing.is_empty(), "no seed decodes for {missing:?}");
}

/// The stream seeds are built from our own framing, one per version and protocol, so
/// each must decode whole: one that does not leaves its version's path unseeded. Those
/// at a cap must not, or they sit beside no rejection path.
#[test]
fn stream_seeds_decode() {
for seed in ingest::seeds().into_iter().chain(request::seeds()) {
assert!(
target(seed.target)(&seed.data),
"{} seed {:x?} does not decode",
seed.target,
seed.data
);
}
for seed in ingest::limit_seeds() {
assert!(!ingest(&seed.data), "ingest seed {:x?} decodes past its cap", seed.data);
}
}

/// Replay the crash inputs committed under `fuzz/regressions/`, so a bug the fuzzer
/// found stays fixed on the stable toolchain, in the suite CI already runs.
#[test]
Expand Down
Loading
Loading