| Author | Andy Green <andy@warmcat.com> 2026-10-05 13:43 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-05 16:11 UTC | | Tree | 3b357d3523300b2ab36ef778f46d526c288474eb Raw Patch | | | fuzz: the ws server's frame parser | fuzz: the ws server's frame parser
The target ws-server is C's fuzz-ws: a client's frames as a server reads
them after the upgrade. The first byte chooses a 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. Text is handed over a
piece at a time once each checks out, so of a message refused as not
UTF-8, how much of its good start went first may differ with the split,
the one only starting the other. Every call must take 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, and what is written is whole,
final, unmasked frames with a close last.
Seeded from C's fuzz/fuzz-ws/seeds, and two regress- seeds from its first
minutes: a pong written after our own close, now forgotten as C forgets
it, and a close of one byte echoed, which C does too (RFC 6455 5.5.1
does not allow it) and which the oracle takes only as that echo.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
|
diff --git a/Cargo.lock b/Cargo.lock
index 38abb00..e141682 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,7 @@ dependencies = [
"npro-core",
"npro-h1",
"npro-test",
+ "npro-ws",
]
[[package]]
diff --git a/crates/npro-fuzz/Cargo.toml b/crates/npro-fuzz/Cargo.toml
index 776a308..9372f76 100644
--- a/crates/npro-fuzz/Cargo.toml
+++ b/crates/npro-fuzz/Cargo.toml
@@ -13,6 +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" }
[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 bba862e..61c6204 100644
--- a/crates/npro-fuzz/src/lib.rs
+++ b/crates/npro-fuzz/src/lib.rs
@@ -22,9 +22,11 @@
mod h1;
mod targets;
+mod ws;
pub use h1::{chunked, h1_request, h1_response};
pub use targets::{base64, sha1, transcript, utf8};
+pub use ws::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>`.
@@ -53,11 +55,13 @@ pub enum Target {
H1Response,
/// [`chunked`]: the chunked transfer coding's decoder.
Chunked,
+ /// [`ws_server`]: the ws frame parser, as a server.
+ WsServer,
}
impl Target {
/// Every target.
- pub const ALL: [Self; 7] = [
+ pub const ALL: [Self; 8] = [
Self::Utf8,
Self::Sha1,
Self::Base64,
@@ -65,6 +69,7 @@ impl Target {
Self::H1Request,
Self::H1Response,
Self::Chunked,
+ Self::WsServer,
];
/// The target's name.
@@ -78,6 +83,7 @@ impl Target {
Self::H1Request => "h1-request",
Self::H1Response => "h1-response",
Self::Chunked => "chunked",
+ Self::WsServer => "ws-server",
}
}
@@ -96,6 +102,7 @@ impl Target {
Self::H1Request => h1_request(data),
Self::H1Response => h1_response(data),
Self::Chunked => chunked(data),
+ Self::WsServer => ws_server(data),
}
}
}
diff --git a/crates/npro-fuzz/src/ws.rs b/crates/npro-fuzz/src/ws.rs
new file mode 100644
index 0000000..9abe2db
--- /dev/null
+++ b/crates/npro-fuzz/src/ws.rs
@@ -0,0 +1,222 @@
+//! The ws target: a server's frame parser, as C's `fuzz-ws` has it, after
+//! an upgrade.
+
+use npro_ws::conn::{Event, Kind, 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;
+
+/// What the application was handed, a message being whole once it is.
+#[derive(Debug, PartialEq, Eq)]
+enum Given {
+ Message(Kind, Vec<u8>),
+ Pong(Vec<u8>),
+ PeerClose(Vec<u8>),
+}
+
+/// 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.
+#[derive(Debug, PartialEq, Eq)]
+struct Run {
+ given: Vec<Given>,
+ open: Option<(Kind, Vec<u8>)>,
+ wrote: Vec<u8>,
+ close: Option<npro_ws::conn::Close>,
+}
+
+struct Nothing;
+
+impl npro_h1::server::TxSource for Nothing {
+ fn fill(&mut self, _: &mut [u8]) -> usize {
+ 0
+ }
+}
+
+/// 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"");
+ let mut given = Vec::new();
+ let mut open: Option<(Kind, Vec<u8>)> = None;
+ let mut buf = Vec::new();
+ for piece in pieces {
+ buf.clear();
+ buf.extend_from_slice(piece);
+ let mut at = 0usize;
+ while let Some(input) = buf.get_mut(at..).filter(|i| !i.is_empty()) {
+ let rx = ws.rx(input);
+ if rx.consumed == 0 {
+ finding(
+ 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"));
+ }
+ match ev {
+ Event::Message {
+ kind,
+ data,
+ first,
+ last,
+ } => {
+ let mut m = match (first, open.take()) {
+ (true, None) => (kind, Vec::new()),
+ (false, Some(m)) if m.0 == kind => m,
+ (_, was) => finding(
+ TARGET,
+ format_args!("{how}: first {first} with {was:?} open"),
+ ),
+ };
+ m.1.extend_from_slice(data);
+ if last {
+ given.push(Given::Message(m.0, m.1));
+ } else {
+ open = Some(m);
+ }
+ }
+ Event::Pong(p) => given.push(Given::Pong(p.to_vec())),
+ Event::PeerClose(p) => given.push(Given::PeerClose(p.to_vec())),
+ }
+ }
+ }
+ let mut wrote = Vec::new();
+ let mut out = [0u8; 64];
+ loop {
+ let n = ws.tx(&mut out, &mut Nothing);
+ if n == 0 {
+ break;
+ }
+ wrote.extend_from_slice(out.get(..n).unwrap_or_default());
+ }
+ Run {
+ given,
+ open,
+ wrote,
+ close: ws.close(),
+ }
+}
+
+/// 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.
+///
+/// `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]>) {
+ 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}"));
+ }
+ let Some((payload, after)) = tail.split_at_checked(len) else {
+ finding(
+ TARGET,
+ format_args!("wrote {} of a {len} byte frame", tail.len()),
+ );
+ };
+ match op {
+ // a pong, of a ping's payload
+ 0xa => {}
+ 0x8 => {
+ let echoed = peer_close == Some(payload);
+ if (payload.len() == 1 && !echoed) || !after.is_empty() {
+ finding(
+ TARGET,
+ format_args!("wrote a close {payload:?} then {after:?}"),
+ );
+ }
+ }
+ _ => finding(
+ TARGET,
+ format_args!("wrote opcode {op:#x} with nothing sent"),
+ ),
+ }
+ rest = after;
+ }
+ if !rest.is_empty() {
+ finding(
+ TARGET,
+ format_args!("wrote a frame of {} bytes", rest.len()),
+ );
+ }
+}
+
+/// The text message a run leaves open, empty if none; `None` for binary.
+fn open_text(r: &Run) -> Option<&[u8]> {
+ match &r.open {
+ None => Some(&[]),
+ Some((Kind::Text, t)) => Some(t),
+ Some((Kind::Binary, _)) => None,
+ }
+}
+
+/// 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 {
+ 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]) {
+ 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));
+ if whole.given != pieces.given
+ || whole.wrote != pieces.wrote
+ || whole.close != pieces.close
+ || !opens_agree(&whole, &pieces)
+ {
+ finding(
+ TARGET,
+ format_args!("whole {whole:?}, in pieces {pieces:?}"),
+ );
+ }
+ let texts = whole.given.iter().filter_map(|g| match g {
+ Given::Message(Kind::Text, t) => Some(t),
+ Given::Message(Kind::Binary, _) | Given::Pong(_) | Given::PeerClose(_) => None,
+ });
+ for t in texts {
+ if core::str::from_utf8(t).is_err() {
+ 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);
+}
diff --git a/crates/npro-fuzz/tests/smoke.rs b/crates/npro-fuzz/tests/smoke.rs
index 230cf2e..3dfd76f 100644
--- a/crates/npro-fuzz/tests/smoke.rs
+++ b/crates/npro-fuzz/tests/smoke.rs
@@ -227,6 +227,11 @@ fn chunked() {
}
#[test]
+fn ws_server() {
+ smoke(Target::WsServer, seeded).unwrap();
+}
+
+#[test]
fn every_target_has_a_smoke_test() {
// the tests above, by name: a new target needs its own
let tested = [
@@ -237,6 +242,7 @@ fn every_target_has_a_smoke_test() {
"h1-request",
"h1-response",
"chunked",
+ "ws-server",
];
assert_eq!(Target::ALL.map(Target::name), tested);
}
diff --git a/docs/fuzzing.md b/docs/fuzzing.md
index 7d2e732..2931d03 100644
--- a/docs/fuzzing.md
+++ b/docs/fuzzing.md
@@ -25,6 +25,7 @@ Fuzzing runs in three places:
| `h1-request` | `npro_h1::head` as a server | the head in one piece and in pieces comes to the same verdict and leaves the same table; a refused head stays refused; no value holds a CR, LF or NUL; a complete request's path from `/` has no `//`, `/./` or `/../` step and no `/.` or `/..` at its end. The top bit of the first byte picks a 256 byte table with token limits, so limits are within reach |
| `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 |
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 40c918f..101715e 100644
--- a/fuzz/Cargo.lock
+++ b/fuzz/Cargo.lock
@@ -80,6 +80,7 @@ dependencies = [
"npro-core",
"npro-h1",
"npro-test",
+ "npro-ws",
]
[[package]]
@@ -99,6 +100,14 @@ name = "npro-test"
version = "0.0.2"
[[package]]
+name = "npro-ws"
+version = "0.0.2"
+dependencies = [
+ "npro-core",
+ "npro-h1",
+]
+
+[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
index 1757568..f87317c 100644
--- a/fuzz/Cargo.toml
+++ b/fuzz/Cargo.toml
@@ -68,6 +68,13 @@ test = false
doc = false
bench = false
+[[bin]]
+name = "ws-server"
+path = "fuzz_targets/ws_server.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 2d4afe2..5b1892b 100644
--- a/fuzz/deny.toml
+++ b/fuzz/deny.toml
@@ -36,6 +36,7 @@ allow = [
"npro-fuzz-targets",
"npro-fuzz",
"npro-h1",
+ "npro-ws",
"npro-core",
"npro-test",
diff --git a/fuzz/fuzz_targets/ws_server.rs b/fuzz/fuzz_targets/ws_server.rs
new file mode 100644
index 0000000..5004956
--- /dev/null
+++ b/fuzz/fuzz_targets/ws_server.rs
@@ -0,0 +1,5 @@
+//! libFuzzer target for [`npro_fuzz::Target::WsServer`].
+
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| npro_fuzz::Target::WsServer.run(data));
diff --git a/fuzz/seeds/README.md b/fuzz/seeds/README.md
index 2432fc5..4779106 100644
--- a/fuzz/seeds/README.md
+++ b/fuzz/seeds/README.md
@@ -22,7 +22,9 @@ The protocol crates' targets start from the C library's corpora
0, named as there (`absuri.http`, `get.http`...), and some of
`crates/npro-test/h1/requests/`, named as there;
- `h1-response` and `chunked`: some of `crates/npro-test/h1/responses/`
- and `chunked/`.
+ and `chunked/`;
+- `ws-server`: C's `fuzz/fuzz-ws/seeds`, each behind a control byte of 0,
+ named as there.
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-server/binary.ws b/fuzz/seeds/ws-server/binary.ws
new file mode 100644
index 0000000..1a02667
Binary files /dev/null and b/fuzz/seeds/ws-server/binary.ws differ
diff --git a/fuzz/seeds/ws-server/close-1000.ws b/fuzz/seeds/ws-server/close-1000.ws
new file mode 100644
index 0000000..e2f3267
Binary files /dev/null and b/fuzz/seeds/ws-server/close-1000.ws differ
diff --git a/fuzz/seeds/ws-server/empty-text.ws b/fuzz/seeds/ws-server/empty-text.ws
new file mode 100644
index 0000000..ebcc0fa
Binary files /dev/null and b/fuzz/seeds/ws-server/empty-text.ws differ
diff --git a/fuzz/seeds/ws-server/fragmented.ws b/fuzz/seeds/ws-server/fragmented.ws
new file mode 100644
index 0000000..6afb346
Binary files /dev/null and b/fuzz/seeds/ws-server/fragmented.ws differ
diff --git a/fuzz/seeds/ws-server/ping.ws b/fuzz/seeds/ws-server/ping.ws
new file mode 100644
index 0000000..553ebe5
Binary files /dev/null and b/fuzz/seeds/ws-server/ping.ws differ
diff --git a/fuzz/seeds/ws-server/pong.ws b/fuzz/seeds/ws-server/pong.ws
new file mode 100644
index 0000000..592f3c2
Binary files /dev/null and b/fuzz/seeds/ws-server/pong.ws differ
diff --git a/fuzz/seeds/ws-server/regress-one-byte-close b/fuzz/seeds/ws-server/regress-one-byte-close
new file mode 100644
index 0000000..f81704f
Binary files /dev/null and b/fuzz/seeds/ws-server/regress-one-byte-close differ
diff --git a/fuzz/seeds/ws-server/regress-pong-after-close b/fuzz/seeds/ws-server/regress-pong-after-close
new file mode 100644
index 0000000..4fb9471
--- /dev/null
+++ b/fuzz/seeds/ws-server/regress-pong-after-close
@@ -0,0 +1 @@
+‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰‰--ÿÿÿÿ
\ No newline at end of file
diff --git a/fuzz/seeds/ws-server/text.ws b/fuzz/seeds/ws-server/text.ws
new file mode 100644
index 0000000..cff160b
Binary files /dev/null and b/fuzz/seeds/ws-server/text.ws differ
diff --git a/fuzz/seeds/ws-server/unmasked.ws b/fuzz/seeds/ws-server/unmasked.ws
new file mode 100644
index 0000000..95864b3
Binary files /dev/null and b/fuzz/seeds/ws-server/unmasked.ws differ
|