Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / fuzz / seeds / h1-response / set-cookies.http
Author[]Andy Green <andy@warmcat.com> 2026-10-05 16:38 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-10-05 19:18 UTC
Treeae28bc2ec34068c1c5275077b2a0a0d9f7cbc176   Raw Patch
 
fuzz: the ws client's frame parser
fuzz: the ws client's frame parser

The target ws-client is ws-server from the client's side: a server's
frames as a client reads them, whole and in pieces, with the same
oracle, what the client writes checked as masked frames, with a fixed
mask from the harness.  The harness now takes either end.

C has no client corpus, so it is seeded with what the server sent after
the 101 in the ws-client transcripts but the pmd ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
diff --git a/crates/npro-fuzz/src/lib.rs b/crates/npro-fuzz/src/lib.rs index 61c6204..0ee3415 100644 --- a/crates/npro-fuzz/src/lib.rs +++ b/crates/npro-fuzz/src/lib.rs @@ -26,7 +26,7 @@ mod ws; pub use h1::{chunked, h1_request, h1_response}; pub use targets::{base64, sha1, transcript, utf8}; -pub use ws::ws_server; +pub use ws::{ws_client, ws_server}; /// A fuzz target: its name is the libFuzzer target's, the seed directory's /// under `fuzz/seeds/`, and the corpus's under `corpus-<name>`. @@ -57,11 +57,13 @@ pub enum Target { Chunked, /// [`ws_server`]: the ws frame parser, as a server. WsServer, + /// [`ws_client`]: the ws frame parser, as a client. + WsClient, } impl Target { /// Every target. - pub const ALL: [Self; 8] = [ + pub const ALL: [Self; 9] = [ Self::Utf8, Self::Sha1, Self::Base64, @@ -70,6 +72,7 @@ impl Target { Self::H1Response, Self::Chunked, Self::WsServer, + Self::WsClient, ]; /// The target's name. @@ -84,6 +87,7 @@ impl Target { Self::H1Response => "h1-response", Self::Chunked => "chunked", Self::WsServer => "ws-server", + Self::WsClient => "ws-client", } } @@ -103,6 +107,7 @@ impl Target { Self::H1Response => h1_response(data), Self::Chunked => chunked(data), Self::WsServer => ws_server(data), + Self::WsClient => ws_client(data), } } } diff --git a/crates/npro-fuzz/src/ws.rs b/crates/npro-fuzz/src/ws.rs index 9abe2db..824ef36 100644 --- a/crates/npro-fuzz/src/ws.rs +++ b/crates/npro-fuzz/src/ws.rs @@ -1,16 +1,31 @@ -//! The ws target: a server's frame parser, as C's `fuzz-ws` has it, after -//! an upgrade. +//! The ws targets: a server's frame parser, as C's `fuzz-ws` has it, after +//! an upgrade, and a client's. -use npro_ws::conn::{Event, Kind, Ws}; +use npro_core::random::{Random, Unavailable}; +use npro_ws::conn::{Close, Event, Kind, Role, Side, Ws}; use crate::targets::{Pieces, control, finding}; -const TARGET: &str = "ws-server"; - /// The most a piece is unmasked into: the fuzzer's input is copied here, /// since the parser unmasks where the bytes lie. const MAX_INPUT: usize = 64 * 1024; +/// The mask a client's frames are written with: not zero, so masking is +/// done, and fixed, so a run is its input's alone. +const MASK: [u8; 4] = [0x5a, 0xa5, 0x0f, 0xf0]; + +/// A client's masks. +struct FixedMask; + +impl Random for FixedMask { + fn fill(&mut self, buf: &mut [u8]) -> Result<(), Unavailable> { + for (b, m) in buf.iter_mut().zip(MASK.iter().cycle()) { + *b = *m; + } + Ok(()) + } +} + /// What the application was handed, a message being whole once it is. #[derive(Debug, PartialEq, Eq)] enum Given { @@ -20,13 +35,13 @@ enum Given { } /// Everything a run came to: what the application was handed, the part of -/// a message still open, what the server wrote, and how it closed. +/// a message still open, what was written, and how it closed. #[derive(Debug, PartialEq, Eq)] struct Run { given: Vec<Given>, open: Option<(Kind, Vec<u8>)>, wrote: Vec<u8>, - close: Option<npro_ws::conn::Close>, + close: Option<Close>, } struct Nothing; @@ -37,11 +52,15 @@ impl npro_h1::server::TxSource for Nothing { } } -/// Feeds `pieces` to a fresh server, checking what each call says as it -/// goes; writes nothing until the end, so the run does not depend on when -/// the pong slot is drained. -fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run { - let mut ws = Ws::server(b""); +/// Feeds `pieces` to `ws`, checking what each call says as it goes; +/// writes nothing until the end, so the run does not depend on when the +/// pong slot is drained. +fn run<'a, P: Role>( + target: &str, + how: &str, + mut ws: Ws<P>, + pieces: impl Iterator<Item = &'a [u8]>, +) -> Run { let mut given = Vec::new(); let mut open: Option<(Kind, Vec<u8>)> = None; let mut buf = Vec::new(); @@ -53,14 +72,14 @@ fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run { let rx = ws.rx(input); if rx.consumed == 0 { finding( - TARGET, + target, format_args!("{how}: took none of {} bytes", input.len()), ); } at = at.saturating_add(rx.consumed); let Some(ev) = rx.event else { continue }; if matches!(given.last(), Some(Given::PeerClose(_))) { - finding(TARGET, format_args!("{how}: {ev:?} after the peer's close")); + finding(target, format_args!("{how}: {ev:?} after the peer's close")); } match ev { Event::Message { @@ -73,7 +92,7 @@ fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run { (true, None) => (kind, Vec::new()), (false, Some(m)) if m.0 == kind => m, (_, was) => finding( - TARGET, + target, format_args!("{how}: first {first} with {was:?} open"), ), }; @@ -106,52 +125,83 @@ fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run { } } -/// The frames a server wrote, checked as a client would read them: each -/// whole, final and unmasked, a control frame at most 125 bytes, a close's -/// payload a code and its reason, and nothing after a close. +/// The frames `side` wrote, checked as its peer would read them, and +/// returned as their opcodes and payloads: each whole and final, masked +/// with [`MASK`] by a client and not by a server, a control frame at most +/// 125 bytes, a close's payload a code and its reason, and nothing after a +/// close. /// /// `peer_close` is the payload of the peer's close, if it sent one. C /// answers with it whatever it is, a lone byte included, which RFC 6455 /// 5.5.1 does not allow, and npro does as C does: so a close of one byte /// is taken from npro only as that echo. -fn check_written(wrote: &[u8], peer_close: Option<&[u8]>) { +fn written( + target: &str, + side: Side, + wrote: &[u8], + peer_close: Option<&[u8]>, +) -> Vec<(u8, Vec<u8>)> { + let masked = match side { + Side::Server => 0, + Side::Client => 0x80, + }; + let mut frames = Vec::new(); let mut rest = wrote; while let [b0, b1, tail @ ..] = rest { let (op, len) = (b0 & 0x0f, usize::from(b1 & 0x7f)); - if b0 & 0xf0 != 0x80 || b1 & 0x80 != 0 || len > 125 { - finding(TARGET, format_args!("wrote a frame {b0:#04x} {b1:#04x}")); + if b0 & 0xf0 != 0x80 || b1 & 0x80 != masked || len > 125 { + finding(target, format_args!("wrote a frame {b0:#04x} {b1:#04x}")); } + let tail = if masked == 0 { + tail + } else { + match tail.split_first_chunk::<4>() { + Some((m, t)) if *m == MASK => t, + _ => finding(target, format_args!("wrote a frame without the mask")), + } + }; let Some((payload, after)) = tail.split_at_checked(len) else { finding( - TARGET, + target, format_args!("wrote {} of a {len} byte frame", tail.len()), ); }; + let payload: Vec<u8> = if masked == 0 { + payload.to_vec() + } else { + payload + .iter() + .zip(MASK.iter().cycle()) + .map(|(b, m)| b ^ m) + .collect() + }; match op { // a pong, of a ping's payload 0xa => {} 0x8 => { - let echoed = peer_close == Some(payload); + let echoed = peer_close == Some(payload.as_slice()); if (payload.len() == 1 && !echoed) || !after.is_empty() { finding( - TARGET, + target, format_args!("wrote a close {payload:?} then {after:?}"), ); } } _ => finding( - TARGET, + target, format_args!("wrote opcode {op:#x} with nothing sent"), ), } + frames.push((op, payload)); rest = after; } if !rest.is_empty() { finding( - TARGET, + target, format_args!("wrote a frame of {} bytes", rest.len()), ); } + frames } /// The text message a run leaves open, empty if none; `None` for binary. @@ -165,43 +215,44 @@ fn open_text(r: &Run) -> Option<&[u8]> { /// Whether two runs leave the same message open. Text is handed over a /// piece at a time once each piece checks out, so where it turns out not to -/// be UTF-8, how much of its good start went first depends on the split: -/// then, and only then, one run's open message need only start the other's. -fn opens_agree(a: &Run, b: &Run) -> bool { +/// be UTF-8, `bad_utf8`, how much of its good start went first depends on +/// the split: then, and only then, one run's open message need only start +/// the other's. +fn opens_agree(a: &Run, b: &Run, bad_utf8: bool) -> bool { if a.open == b.open { return true; } - let bad_utf8 = a.wrote.get(..4) == Some(b"\x88\x0a\x03\xef".as_slice()) - || a.wrote.get(..4) == Some(b"\x88\x0e\x03\xef".as_slice()); match (open_text(a), open_text(b)) { (Some(x), Some(y)) => bad_utf8 && (x.starts_with(y) || y.starts_with(x)), (None, _) | (_, None) => false, } } -/// A client's frames as a server reads them after the upgrade: C's -/// `fuzz-ws`. -/// -/// The first byte chooses how the rest is split. In one piece and in -/// pieces, the server must hand the application the same messages, pongs -/// and close, write the same, and close the same, of a message it refuses -/// as not UTF-8 having handed over a start of it that may differ (see -/// `opens_agree`); every call must take -/// something; a message's pieces must say where it starts; nothing may -/// follow the peer's close; a whole text message must be UTF-8 by -/// `core::str`; and what the server writes must be frames a client reads. -pub fn ws_server(data: &[u8]) { +/// Runs `frames` through `make()`'s connection whole and in pieces, and +/// checks both: see [`ws_server`]. +fn frames<P: Role>(target: &str, data: &[u8], make: impl Fn() -> Ws<P>) { let (ctl, frames) = control(data); let frames = frames.get(..MAX_INPUT).unwrap_or(frames); - let whole = run("whole", core::iter::once(frames)); - let pieces = run("in pieces", Pieces::new(ctl, frames)); + let side = make().side(); + let whole = run(target, "whole", make(), core::iter::once(frames)); + let pieces = run(target, "in pieces", make(), Pieces::new(ctl, frames)); + + let peer_close = whole.given.iter().find_map(|g| match g { + Given::PeerClose(p) => Some(p.as_slice()), + Given::Message(..) | Given::Pong(_) => None, + }); + let sent = written(target, side, &whole.wrote, peer_close); + let bad_utf8 = matches!( + sent.first(), + Some((0x8, p)) if p.get(..2) == Some(&1007u16.to_be_bytes()[..]) + ); if whole.given != pieces.given || whole.wrote != pieces.wrote || whole.close != pieces.close - || !opens_agree(&whole, &pieces) + || !opens_agree(&whole, &pieces, bad_utf8) { finding( - TARGET, + target, format_args!("whole {whole:?}, in pieces {pieces:?}"), ); } @@ -211,12 +262,28 @@ pub fn ws_server(data: &[u8]) { }); for t in texts { if core::str::from_utf8(t).is_err() { - finding(TARGET, format_args!("text {t:?} is not UTF-8")); + finding(target, format_args!("text {t:?} is not UTF-8")); } } - let peer_close = whole.given.iter().find_map(|g| match g { - Given::PeerClose(p) => Some(p.as_slice()), - Given::Message(..) | Given::Pong(_) => None, - }); - check_written(&whole.wrote, peer_close); +} + +/// A client's frames as a server reads them after the upgrade: C's +/// `fuzz-ws`. +/// +/// The first byte chooses how the rest is split. In one piece and in +/// pieces, the server must hand the application the same messages, pongs +/// and close, write the same, and close the same, of a message it refuses +/// as not UTF-8 having handed over a start of it that may differ (see +/// `opens_agree`); every call must take something; a message's pieces must +/// say where it starts; nothing may follow the peer's close; a whole text +/// message must be UTF-8 by `core::str`; and what the server writes must be +/// frames a client reads. +pub fn ws_server(data: &[u8]) { + frames("ws-server", data, || Ws::server(b"")); +} + +/// A server's frames as a client reads them after the upgrade: as +/// [`ws_server`], from the client's side, which writes its frames masked. +pub fn ws_client(data: &[u8]) { + frames("ws-client", data, || Ws::client(FixedMask)); } diff --git a/crates/npro-fuzz/tests/smoke.rs b/crates/npro-fuzz/tests/smoke.rs index 3dfd76f..c741a39 100644 --- a/crates/npro-fuzz/tests/smoke.rs +++ b/crates/npro-fuzz/tests/smoke.rs @@ -232,6 +232,11 @@ fn ws_server() { } #[test] +fn ws_client() { + smoke(Target::WsClient, seeded).unwrap(); +} + +#[test] fn every_target_has_a_smoke_test() { // the tests above, by name: a new target needs its own let tested = [ @@ -243,6 +248,7 @@ fn every_target_has_a_smoke_test() { "h1-response", "chunked", "ws-server", + "ws-client", ]; assert_eq!(Target::ALL.map(Target::name), tested); } diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 2931d03..6846d0a 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -26,6 +26,7 @@ Fuzzing runs in three places: | `h1-response` | `npro_h1::head` as a client | as `h1-request` | | `chunked` | `npro_h1::chunked::Dechunk` | a second reading of RFC 9112 7.1 with C's bounds, written apart from the decoder: the same data, ending at the same byte, or refused, in one piece and in pieces | | `ws-server` | `npro_ws::conn::Ws` as a server, after the upgrade, as C's `fuzz-ws` | in one piece and in pieces, the same messages, pongs and peer's close handed to the application, the same frames written and the same close; where text turns out not to be UTF-8, how much of its good start was handed over may differ with the split, the one only starting the other. Every call takes something; a message's pieces say where it starts; nothing follows the peer's close; a whole text message is UTF-8 by `core::str`; what is written is whole, final, unmasked frames, a close last | +| `ws-client` | `npro_ws::conn::Ws` as a client, after the upgrade | as `ws-server`, from the client's side: what it writes is masked, with a fixed mask from the harness | A target that splits its input to feed it in pieces takes the split from the input's first byte, so libFuzzer explores the split like the rest of diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index f87317c..e65a282 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -75,6 +75,13 @@ test = false doc = false bench = false +[[bin]] +name = "ws-client" +path = "fuzz_targets/ws_client.rs" +test = false +doc = false +bench = false + [lints.rust] unsafe_code = "forbid" unexpected_cfgs = "deny" diff --git a/fuzz/fuzz_targets/ws_client.rs b/fuzz/fuzz_targets/ws_client.rs new file mode 100644 index 0000000..0d5646a --- /dev/null +++ b/fuzz/fuzz_targets/ws_client.rs @@ -0,0 +1,5 @@ +//! libFuzzer target for [`npro_fuzz::Target::WsClient`]. + +#![no_main] + +libfuzzer_sys::fuzz_target!(|data: &[u8]| npro_fuzz::Target::WsClient.run(data)); diff --git a/fuzz/seeds/README.md b/fuzz/seeds/README.md index 4779106..d4da004 100644 --- a/fuzz/seeds/README.md +++ b/fuzz/seeds/README.md @@ -24,7 +24,10 @@ The protocol crates' targets start from the C library's corpora - `h1-response` and `chunked`: some of `crates/npro-test/h1/responses/` and `chunked/`; - `ws-server`: C's `fuzz/fuzz-ws/seeds`, each behind a control byte of 0, - named as there. + named as there; +- `ws-client`: C has no client corpus, so what the server sent after the + 101 in each of the `ws-client-*` transcripts but the pmd ones, behind a + control byte of 0, named for the transcript. A `tight-*` seed's control byte has its top bit set, choosing the small table with token limits. A seed named `regress-*` is an input the fuzzer diff --git a/fuzz/seeds/ws-client/ws-client-huge-frame.ws b/fuzz/seeds/ws-client/ws-client-huge-frame.ws new file mode 100644 index 0000000..f8aba4e Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-huge-frame.ws differ diff --git a/fuzz/seeds/ws-client/ws-client-ping-close.ws b/fuzz/seeds/ws-client/ws-client-ping-close.ws new file mode 100644 index 0000000..fe36e9d Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-ping-close.ws differ diff --git a/fuzz/seeds/ws-client/ws-client-rsv1-no-ext.ws b/fuzz/seeds/ws-client/ws-client-rsv1-no-ext.ws new file mode 100644 index 0000000..7307c7b Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-rsv1-no-ext.ws differ diff --git a/fuzz/seeds/ws-client/ws-client-rsv2.ws b/fuzz/seeds/ws-client/ws-client-rsv2.ws new file mode 100644 index 0000000..e887c97 Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-rsv2.ws differ diff --git a/fuzz/seeds/ws-client/ws-client.ws b/fuzz/seeds/ws-client/ws-client.ws new file mode 100644 index 0000000..83d248c Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client.ws differ
Page fetched 0s ago, creation time: 21ms (vhost etag hits: 0%, cache hits: 0%)