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-unknown-header-first.json
Author[]Andy Green <andy@warmcat.com> 2026-10-05 13:43 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-10-05 16:11 UTC
Tree3b357d3523300b2ab36ef778f46d526c288474eb   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
Page fetched 0s ago, creation time: 13ms (vhost etag hits: 0%, cache hits: 0%)