diff --git a/.gitea/workflows/fuzz.yml b/.gitea/workflows/fuzz.yml index 1238ee0..07352d6 100644 --- a/.gitea/workflows/fuzz.yml +++ b/.gitea/workflows/fuzz.yml @@ -1,4 +1,4 @@ -name: Proto Fuzz Smoke +name: Fuzz Smoke on: push: @@ -71,7 +71,7 @@ jobs: # regression; this is not meant to be exhaustive fuzzing. run: | set -euo pipefail - for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do + for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do echo "::group::fuzz smoke: ${target}" RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \ -max_total_time=10 \ diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 30f4db0..2162b6b 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -8,6 +8,18 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "block-buffer" version = "0.12.1" @@ -35,6 +47,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "cmov" version = "0.5.4" @@ -126,10 +144,24 @@ dependencies = [ name = "irtt-proto-fuzz" version = "0.0.0" dependencies = [ + "arbitrary", "irtt-proto", + "irtt-server", "libfuzzer-sys", ] +[[package]] +name = "irtt-server" +version = "0.5.2" +dependencies = [ + "getrandom", + "irtt-proto", + "nix", + "socket2", + "thiserror", + "tokio", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -166,6 +198,45 @@ dependencies = [ "digest", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -196,6 +267,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "subtle" version = "2.6.1" @@ -233,6 +314,31 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.20.1" @@ -244,3 +350,24 @@ name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 1d1114e..dad8af1 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -8,11 +8,15 @@ edition = "2021" cargo-fuzz = true [dependencies] +arbitrary = "1.4.2" libfuzzer-sys = "0.4.13" [dependencies.irtt-proto] path = "../crates/irtt-proto" +[dependencies.irtt-server] +path = "../crates/irtt-server" + # Use independent workspace for fuzzers [workspace] members = ["."] @@ -51,3 +55,10 @@ path = "fuzz_targets/decode_varint.rs" test = false doc = false bench = false + +[[bin]] +name = "server_core_datagrams" +path = "fuzz_targets/server_core_datagrams.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md index 2692edf..03b8112 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -1,6 +1,7 @@ -# `irtt-proto` fuzzing +# `irtt-rs` fuzzing -Coverage-guided fuzzing of `irtt-proto`'s wire decoders via +Coverage-guided fuzzing of `irtt-proto` wire decoders and `irtt-server`'s public +`ServerCore::handle_datagram` API via [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer. This project is intentionally isolated from the normal stable/MSRV @@ -22,14 +23,16 @@ of the root workspace, requires nightly Rust, and is not built by | `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated | | `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip | | `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` | +| `server_core_datagrams` | A bounded mix of raw datagrams and production-encoded Open/Echo/Close requests against one `ServerCore`; it varies IPv4/IPv6 peers, session configuration, and bounded (up to 128-byte) HMAC keys, authenticates keyed requests, reuses issued tokens, and asserts the session cap | The invariant every target asserts is: **arbitrary bytes must never panic**. Returning `Err` is normal and expected; a successful decode is also normal. -Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so -libFuzzer spends its time exploring protocol structure rather than -allocating/copying megabytes — production IRTT datagrams are UDP-sized, well -under this bound. +Every target bounds its input (`MAX_INPUT_LEN` in each harness) so libFuzzer +spends its time exploring protocol structure rather than allocating/copying +megabytes. `server_core_datagrams` additionally caps its sequence length and +each individual datagram and configures the core to the same packet cap, so one +libFuzzer input cannot become a tiny DoS benchmark. ## Running diff --git a/fuzz/fuzz_targets/server_core_datagrams.rs b/fuzz/fuzz_targets/server_core_datagrams.rs new file mode 100644 index 0000000..abd8ff7 --- /dev/null +++ b/fuzz/fuzz_targets/server_core_datagrams.rs @@ -0,0 +1,186 @@ +#![no_main] + +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Duration, +}; + +use arbitrary::Unstructured; +use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode}; +use irtt_server::{ServerConfig, ServerCore}; +use libfuzzer_sys::fuzz_target; + +/// Bounds one libFuzzer input as well as the work it can ask the core to do. +/// These are deliberately much smaller than a production UDP payload: the +/// target is stateful parser/session exploration, not packet-size throughput. +const MAX_INPUT_LEN: usize = 32 * 1024; +const MAX_DATAGRAMS: usize = 16; +const MAX_DATAGRAM_LEN: usize = 2 * 1024; +const MAX_SESSIONS: u8 = 8; +const MAX_HMAC_KEY_LEN: usize = 128; + +#[derive(Clone)] +struct FuzzSession { + token: u64, + peer: SocketAddr, + params: Params, +} + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT_LEN { + return; + } + + let mut input = Unstructured::new(data); + let Ok(mut config) = config(&mut input) else { + return; + }; + let hmac_key = match input.arbitrary::() { + Ok(true) => { + let Ok(length) = input.int_in_range(1..=MAX_HMAC_KEY_LEN) else { + return; + }; + input.bytes(length).ok() + } + Ok(false) => None, + Err(_) => return, + }; + if let Some(key) = hmac_key { + config = config.with_hmac_key(key); + } + let mut core = ServerCore::new(config); + let mut sessions = Vec::new(); + + for _ in 0..MAX_DATAGRAMS { + let Ok(operation) = input.arbitrary::() else { + break; + }; + + match operation % 4 { + // Keep arbitrary-byte admission coverage alongside the stateful + // production-encoded requests below. + 0 => raw_datagram(&mut core, &mut input), + 1 => open_session(&mut core, &mut input, hmac_key, &mut sessions), + 2 => echo_session(&mut core, &mut input, hmac_key, &sessions), + _ => close_session(&mut core, &mut input, hmac_key, &mut sessions), + } + + // Arbitrary network input is expected to be rejected or to produce an + // internal error in exceptional host conditions. It must never panic, + // and it may never grow the bounded table past its configured cap. + assert!(core.session_count() <= core.config().max_sessions()); + } +}); + +fn config(input: &mut Unstructured<'_>) -> arbitrary::Result { + let max_sessions = usize::from(input.int_in_range(0..=MAX_SESSIONS)?); + Ok(ServerConfig::default() + .with_max_sessions(max_sessions) + .with_max_packet_length(MAX_DATAGRAM_LEN) + .with_min_send_interval(Duration::ZERO)) +} + +fn raw_datagram(core: &mut ServerCore, input: &mut Unstructured<'_>) { + let (Ok(peer), Ok(length)) = (peer(input), input.int_in_range(0..=MAX_DATAGRAM_LEN)) else { + return; + }; + let Ok(packet) = input.bytes(length) else { + return; + }; + let _ = core.handle_datagram(peer, packet); +} + +fn open_session( + core: &mut ServerCore, + input: &mut Unstructured<'_>, + hmac_key: Option<&[u8]>, + sessions: &mut Vec, +) { + let Ok(peer) = peer(input) else { + return; + }; + let params = session_params(); + let request = encode_request( + RequestToEncode::Open { + params: ¶ms, + no_test: false, + }, + hmac_key, + ) + .expect("fixed valid fuzz open must encode"); + let Ok(Some(reply)) = core.handle_datagram(peer, &request) else { + return; + }; + let Ok(reply) = decode_open_reply(reply.bytes(), hmac_key) else { + return; + }; + sessions.push(FuzzSession { + token: reply.token, + peer, + params: reply.params, + }); +} + +fn echo_session( + core: &mut ServerCore, + input: &mut Unstructured<'_>, + hmac_key: Option<&[u8]>, + sessions: &[FuzzSession], +) { + let Some(session) = + sessions.get(usize::from(input.arbitrary::().unwrap_or(0)) % sessions.len().max(1)) + else { + return; + }; + let sequence = input.arbitrary().unwrap_or_default(); + let request = encode_request( + RequestToEncode::Echo { + token: session.token, + sequence, + params: &session.params, + payload: &[], + }, + hmac_key, + ) + .expect("fixed valid fuzz echo must encode"); + let _ = core.handle_datagram(session.peer, &request); +} + +fn close_session( + core: &mut ServerCore, + input: &mut Unstructured<'_>, + hmac_key: Option<&[u8]>, + sessions: &mut Vec, +) { + let index = usize::from(input.arbitrary::().unwrap_or(0)) % sessions.len().max(1); + let Some(session) = sessions.get(index) else { + return; + }; + let request = encode_request( + RequestToEncode::Close { + token: session.token, + }, + hmac_key, + ) + .expect("fixed valid fuzz close must encode"); + let _ = core.handle_datagram(session.peer, &request); + sessions.swap_remove(index); +} + +fn session_params() -> Params { + Params { + protocol_version: 1, + length: 256, + ..Params::default() + } +} + +fn peer(input: &mut Unstructured<'_>) -> arbitrary::Result { + let port = input.arbitrary()?; + let address = if input.arbitrary()? { + IpAddr::V4(Ipv4Addr::from(input.arbitrary::<[u8; 4]>()?)) + } else { + IpAddr::V6(Ipv6Addr::from(input.arbitrary::<[u8; 16]>()?)) + }; + Ok(SocketAddr::new(address, port)) +}