| Author | Andy Green <andy@warmcat.com> 2026-10-05 20:22 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-06 03:36 UTC | | Tree | edf77046724d4d5a1836f474e300213d24a7b1c1 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
|