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 / crates / npro-test / transcripts / h1-reqline-version-2.json
Author[]Andy Green <andy@warmcat.com> 2026-10-05 20:22 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-10-06 03:36 UTC
Treeedf77046724d4d5a1836f474e300213d24a7b1c1   Raw Patch
 
fuzz: the ws frame parser with permessage-deflate
fuzz: the ws frame parser with permessage-deflate

The target ws-pmd is C's fuzz-ws-pmd: a client's frames as a server with
permessage-deflate reads them, seeded from C's fuzz/fuzz-ws-pmd/seeds,
bomb-2mb-zeros among them.  A message may inflate to 1MiB, so the bomb
reaches the limit at fuzzing speed.  The harness now drains the
connection while it says it has more to give without input, and checks
that it gives it.

Messages that end must agree exactly, whole and in pieces.  How far the
inflater gets with the input it has depends on where that stops, so a
message left unfinished need only start the other run's; and where a
message does not inflate, text in it that is not UTF-8 may be refused
first, or not, both runs failing.  Two regress- seeds are the inputs
that showed each, and npro-fuzz builds npro-ws with pmd, the fuzz
workspace admitting miniz_oxide and adler2 as the main one does.

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/Cargo.toml b/crates/npro-fuzz/Cargo.toml index 9372f76..7ff11ff 100644 --- a/crates/npro-fuzz/Cargo.toml +++ b/crates/npro-fuzz/Cargo.toml @@ -13,7 +13,7 @@ repository.workspace = true npro-core = { path = "../npro-core" } npro-h1 = { path = "../npro-h1" } npro-test = { path = "../npro-test" } -npro-ws = { path = "../npro-ws" } +npro-ws = { path = "../npro-ws", features = ["pmd"] } [dev-dependencies] npro-core = { path = "../npro-core", features = ["replay"] } diff --git a/crates/npro-fuzz/src/lib.rs b/crates/npro-fuzz/src/lib.rs index 0ee3415..33b0161 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_client, ws_server}; +pub use ws::{ws_client, ws_pmd, 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>`. @@ -59,11 +59,14 @@ pub enum Target { WsServer, /// [`ws_client`]: the ws frame parser, as a client. WsClient, + /// [`ws_pmd`]: the ws frame parser with permessage-deflate, as a + /// server. + WsPmd, } impl Target { /// Every target. - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 10] = [ Self::Utf8, Self::Sha1, Self::Base64, @@ -73,6 +76,7 @@ impl Target { Self::Chunked, Self::WsServer, Self::WsClient, + Self::WsPmd, ]; /// The target's name. @@ -88,6 +92,7 @@ impl Target { Self::Chunked => "chunked", Self::WsServer => "ws-server", Self::WsClient => "ws-client", + Self::WsPmd => "ws-pmd", } } @@ -108,6 +113,7 @@ impl Target { Self::Chunked => chunked(data), Self::WsServer => ws_server(data), Self::WsClient => ws_client(data), + Self::WsPmd => ws_pmd(data), } } } diff --git a/crates/npro-fuzz/src/ws.rs b/crates/npro-fuzz/src/ws.rs index 824ef36..5065cda 100644 --- a/crates/npro-fuzz/src/ws.rs +++ b/crates/npro-fuzz/src/ws.rs @@ -1,8 +1,10 @@ //! The ws targets: a server's frame parser, as C's `fuzz-ws` has it, after -//! an upgrade, and a client's. +//! an upgrade, and a client's, and a server's with permessage-deflate, as +//! C's `fuzz-ws-pmd`. use npro_core::random::{Random, Unavailable}; use npro_ws::conn::{Close, Event, Kind, Role, Side, Ws}; +use npro_ws::pmd::Params; use crate::targets::{Pieces, control, finding}; @@ -10,6 +12,10 @@ use crate::targets::{Pieces, control, finding}; /// since the parser unmasks where the bytes lie. const MAX_INPUT: usize = 64 * 1024; +/// The most a message may inflate to in `ws-pmd`: small, so a zip bomb is +/// within the fuzzer's reach, where C's is 256MiB. +const FUZZ_MAX_MESSAGE: u64 = 1 << 20; + /// 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]; @@ -68,13 +74,26 @@ fn run<'a, P: Role>( buf.clear(); buf.extend_from_slice(piece); let mut at = 0usize; - while let Some(input) = buf.get_mut(at..).filter(|i| !i.is_empty()) { + // calls with nothing to take that gave nothing, while it said it + // had more to give + let mut idle = 0u8; + while let Some(input) = buf.get_mut(at..) { + let draining = input.is_empty(); + if draining && !ws.rx_pending() { + break; + } + let len = input.len(); let rx = ws.rx(input); - if rx.consumed == 0 { - finding( - target, - format_args!("{how}: took none of {} bytes", input.len()), - ); + if rx.consumed == 0 && rx.event.is_none() && !draining { + finding(target, format_args!("{how}: took none of {len} bytes")); + } + if draining && rx.event.is_none() { + idle = idle.saturating_add(1); + if idle > 2 { + finding(target, format_args!("{how}: pending, but gives nothing")); + } + } else { + idle = 0; } at = at.saturating_add(rx.consumed); let Some(ev) = rx.event else { continue }; @@ -204,6 +223,14 @@ fn written( frames } +/// Whether what was written is the close refusing text that is not UTF-8. +fn refused_text(sent: &[(u8, Vec<u8>)]) -> bool { + matches!( + sent.first(), + Some((0x8, p)) if p.get(..2) == Some(&1007u16.to_be_bytes()[..]) + ) +} + /// The text message a run leaves open, empty if none; `None` for binary. fn open_text(r: &Run) -> Option<&[u8]> { match &r.open { @@ -213,15 +240,32 @@ 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, `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 { +/// How far two runs may differ in the message they leave open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Open { + /// As it is handed over, a piece as it comes: the same. + Plain, + /// Inflated: how far the inflater gets with the input it has depends + /// on where that input stops, so one may only start the other. The + /// messages that end are compared whole. + Deflated, +} + +/// Whether two runs leave the same message open. A message is handed +/// over a piece at a time as it comes, so where it turns out not to be +/// UTF-8, `bad_utf8`, or the connection is dropped partway, as a deflated +/// message that does not inflate drops it, how much of its start went first +/// depends on the split: then, and with `open` deflated, only then, one +/// run's open message need only start the other's. +fn opens_agree(a: &Run, b: &Run, bad_utf8: bool, open: Open) -> bool { if a.open == b.open { return true; } + if a.close == Some(Close::Release) || open == Open::Deflated { + let bytes = |r: &Run| r.open.as_ref().map_or(Vec::new(), |(_, m)| m.clone()); + let (x, y) = (bytes(a), bytes(b)); + return x.starts_with(&y) || y.starts_with(&x); + } match (open_text(a), open_text(b)) { (Some(x), Some(y)) => bad_utf8 && (x.starts_with(y) || y.starts_with(x)), (None, _) | (_, None) => false, @@ -230,7 +274,7 @@ fn opens_agree(a: &Run, b: &Run, bad_utf8: bool) -> bool { /// 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>) { +fn frames<P: Role>(target: &str, data: &[u8], open: Open, make: impl Fn() -> Ws<P>) { let (ctl, frames) = control(data); let frames = frames.get(..MAX_INPUT).unwrap_or(frames); let side = make().side(); @@ -242,15 +286,18 @@ fn frames<P: Role>(target: &str, data: &[u8], make: impl Fn() -> Ws<P>) { 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, bad_utf8) - { + let sent_in_pieces = written(target, side, &pieces.wrote, peer_close); + let bad_utf8 = refused_text(&sent) || refused_text(&sent_in_pieces); + // a deflated message that does not inflate drops the connection; how + // much it inflates first, before the inflater sees what is wrong, + // depends on the split, so text it gives that is not UTF-8 may be seen + // first, or not: either way, it failed + let failed = |r: &Run, frames_sent: &[(u8, Vec<u8>)]| { + (r.close == Some(Close::Release) && r.wrote.is_empty()) || refused_text(frames_sent) + }; + let ends_agree = (whole.wrote == pieces.wrote && whole.close == pieces.close) + || (open == Open::Deflated && failed(&whole, &sent) && failed(&pieces, &sent_in_pieces)); + if whole.given != pieces.given || !ends_agree || !opens_agree(&whole, &pieces, bad_utf8, open) { finding( target, format_args!("whole {whole:?}, in pieces {pieces:?}"), @@ -274,16 +321,33 @@ fn frames<P: Role>(target: &str, data: &[u8], make: impl Fn() -> Ws<P>) { /// 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 +/// `opens_agree`); every call must take something or give something, and +/// while it says it has more to give without input, give it; 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"")); + frames("ws-server", data, Open::Plain, || 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)); + frames("ws-client", data, Open::Plain, || Ws::client(FixedMask)); +} + +/// A client's frames as a server with permessage-deflate reads them: C's +/// `fuzz-ws-pmd`, whose client offered it with no parameters the server +/// takes. As [`ws_server`], deflated messages inflating to at most 1MiB, +/// so a zip bomb is within reach. How far the inflater gets with the input +/// it has depends on where that stops, so a message left unfinished, whole +/// and in pieces, need only start the other; and where a message does not +/// inflate, text in it that is not UTF-8 may be refused first, or not, +/// both runs failing. +pub fn ws_pmd(data: &[u8]) { + let params = Params::DEFAULT.with_max_message(FUZZ_MAX_MESSAGE); + frames("ws-pmd", data, Open::Deflated, || { + Ws::server(b"").with_pmd(params) + }); } diff --git a/crates/npro-fuzz/tests/smoke.rs b/crates/npro-fuzz/tests/smoke.rs index c741a39..cf645e6 100644 --- a/crates/npro-fuzz/tests/smoke.rs +++ b/crates/npro-fuzz/tests/smoke.rs @@ -237,6 +237,11 @@ fn ws_client() { } #[test] +fn ws_pmd() { + smoke(Target::WsPmd, seeded).unwrap(); +} + +#[test] fn every_target_has_a_smoke_test() { // the tests above, by name: a new target needs its own let tested = [ @@ -249,6 +254,7 @@ fn every_target_has_a_smoke_test() { "chunked", "ws-server", "ws-client", + "ws-pmd", ]; assert_eq!(Target::ALL.map(Target::name), tested); } diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 6846d0a..7691b64 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -27,6 +27,7 @@ Fuzzing runs in three places: | `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 | +| `ws-pmd` | `npro_ws::conn::Ws` as a server with permessage-deflate, as C's `fuzz-ws-pmd` | as `ws-server`, and while it says it has more to give without input (`rx_pending`), it gives it. Messages that end must agree exactly; how far the inflater gets with the input it has depends on where that stops, so a message left unfinished need only start the other run's, and where a message does not inflate, text in it that is not UTF-8 may be refused first, or not, both runs failing. A message may inflate to 1MiB, so the zip bomb seed reaches the limit | 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.lock b/fuzz/Cargo.lock index 101715e..198e1a4 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -3,6 +3,12 @@ version = 4 [[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -70,6 +76,15 @@ dependencies = [ ] [[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] name = "npro-core" version = "0.0.2" @@ -103,6 +118,7 @@ version = "0.0.2" name = "npro-ws" version = "0.0.2" dependencies = [ + "miniz_oxide", "npro-core", "npro-h1", ] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index e65a282..bccf8e4 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -82,6 +82,13 @@ test = false doc = false bench = false +[[bin]] +name = "ws-pmd" +path = "fuzz_targets/ws_pmd.rs" +test = false +doc = false +bench = false + [lints.rust] unsafe_code = "forbid" unexpected_cfgs = "deny" diff --git a/fuzz/deny.toml b/fuzz/deny.toml index 5b1892b..e043973 100644 --- a/fuzz/deny.toml +++ b/fuzz/deny.toml @@ -39,6 +39,9 @@ allow = [ "npro-ws", "npro-core", "npro-test", + # permessage-deflate, as in the main workspace + "miniz_oxide", + "adler2", # libFuzzer's runtime and the fuzz_target! macro "libfuzzer-sys", diff --git a/fuzz/fuzz_targets/ws_pmd.rs b/fuzz/fuzz_targets/ws_pmd.rs new file mode 100644 index 0000000..94d4148 --- /dev/null +++ b/fuzz/fuzz_targets/ws_pmd.rs @@ -0,0 +1,5 @@ +//! libFuzzer target for [`npro_fuzz::Target::WsPmd`]. + +#![no_main] + +libfuzzer_sys::fuzz_target!(|data: &[u8]| npro_fuzz::Target::WsPmd.run(data)); diff --git a/fuzz/seeds/README.md b/fuzz/seeds/README.md index d4da004..a531e41 100644 --- a/fuzz/seeds/README.md +++ b/fuzz/seeds/README.md @@ -25,6 +25,8 @@ The protocol crates' targets start from the C library's corpora and `chunked/`; - `ws-server`: C's `fuzz/fuzz-ws/seeds`, each behind a control byte of 0, named as there; +- `ws-pmd`: C's `fuzz/fuzz-ws-pmd/seeds`, each behind a control byte of + 0, named as there, `bomb-2mb-zeros.ws` among them; - `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. diff --git a/fuzz/seeds/ws-pmd/bad-deflate.ws b/fuzz/seeds/ws-pmd/bad-deflate.ws new file mode 100644 index 0000000..d570374 Binary files /dev/null and b/fuzz/seeds/ws-pmd/bad-deflate.ws differ diff --git a/fuzz/seeds/ws-pmd/binary-compressed.ws b/fuzz/seeds/ws-pmd/binary-compressed.ws new file mode 100644 index 0000000..2c9325d Binary files /dev/null and b/fuzz/seeds/ws-pmd/binary-compressed.ws differ diff --git a/fuzz/seeds/ws-pmd/bomb-2mb-zeros.ws b/fuzz/seeds/ws-pmd/bomb-2mb-zeros.ws new file mode 100644 index 0000000..69b67dd Binary files /dev/null and b/fuzz/seeds/ws-pmd/bomb-2mb-zeros.ws differ diff --git a/fuzz/seeds/ws-pmd/close-1000.ws b/fuzz/seeds/ws-pmd/close-1000.ws new file mode 100644 index 0000000..77de4da Binary files /dev/null and b/fuzz/seeds/ws-pmd/close-1000.ws differ diff --git a/fuzz/seeds/ws-pmd/empty-compressed.ws b/fuzz/seeds/ws-pmd/empty-compressed.ws new file mode 100644 index 0000000..9ac5a97 Binary files /dev/null and b/fuzz/seeds/ws-pmd/empty-compressed.ws differ diff --git a/fuzz/seeds/ws-pmd/fragmented-compressed.ws b/fuzz/seeds/ws-pmd/fragmented-compressed.ws new file mode 100644 index 0000000..a912ae1 Binary files /dev/null and b/fuzz/seeds/ws-pmd/fragmented-compressed.ws differ diff --git a/fuzz/seeds/ws-pmd/fragmented-with-ping.ws b/fuzz/seeds/ws-pmd/fragmented-with-ping.ws new file mode 100644 index 0000000..dcc9e40 Binary files /dev/null and b/fuzz/seeds/ws-pmd/fragmented-with-ping.ws differ diff --git a/fuzz/seeds/ws-pmd/regress-fails-either-way b/fuzz/seeds/ws-pmd/regress-fails-either-way new file mode 100644 index 0000000..a0ecbd1 Binary files /dev/null and b/fuzz/seeds/ws-pmd/regress-fails-either-way differ diff --git a/fuzz/seeds/ws-pmd/regress-unfinished-lag b/fuzz/seeds/ws-pmd/regress-unfinished-lag new file mode 100644 index 0000000..284dedd Binary files /dev/null and b/fuzz/seeds/ws-pmd/regress-unfinished-lag differ diff --git a/fuzz/seeds/ws-pmd/rsv1-on-ping.ws b/fuzz/seeds/ws-pmd/rsv1-on-ping.ws new file mode 100644 index 0000000..3b61fcc Binary files /dev/null and b/fuzz/seeds/ws-pmd/rsv1-on-ping.ws differ diff --git a/fuzz/seeds/ws-pmd/text-compressed.ws b/fuzz/seeds/ws-pmd/text-compressed.ws new file mode 100644 index 0000000..cdb16de Binary files /dev/null and b/fuzz/seeds/ws-pmd/text-compressed.ws differ diff --git a/fuzz/seeds/ws-pmd/text-plain.ws b/fuzz/seeds/ws-pmd/text-plain.ws new file mode 100644 index 0000000..8bd5944 Binary files /dev/null and b/fuzz/seeds/ws-pmd/text-plain.ws differ diff --git a/fuzz/seeds/ws-pmd/two-msgs-context-takeover.ws b/fuzz/seeds/ws-pmd/two-msgs-context-takeover.ws new file mode 100644 index 0000000..acdae00 Binary files /dev/null and b/fuzz/seeds/ws-pmd/two-msgs-context-takeover.ws differ
Page fetched 0s ago, creation time: 5ms (vhost etag hits: 0%, cache hits: 0%)