From bc2bf34ed485f3617e63e8c3948f82de56d25f14 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Tue, 15 Sep 2026 12:39:59 +0700 Subject: [PATCH] test(server): fuzz ServerCore datagram handling --- .gitea/workflows/fuzz.yml | 4 +- fuzz/Cargo.lock | 127 +++++++++++++++++++++ fuzz/Cargo.toml | 11 ++ fuzz/README.md | 15 ++- fuzz/fuzz_targets/server_core_datagrams.rs | 66 +++++++++++ 5 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 fuzz/fuzz_targets/server_core_datagrams.rs 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..dad8ed2 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 sequence of datagrams against one `ServerCore`, with varied IPv4/IPv6 peers and session/HMAC configuration; it asserts the session cap after every datagram | 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..5079af5 --- /dev/null +++ b/fuzz/fuzz_targets/server_core_datagrams.rs @@ -0,0 +1,66 @@ +#![no_main] + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use arbitrary::Unstructured; +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 FUZZ_HMAC_KEY: &[u8] = b"fuzz-server-core-key"; + +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; + }; + if input.arbitrary::().unwrap_or(false) { + config = config.with_hmac_key(FUZZ_HMAC_KEY); + } + let mut core = ServerCore::new(config); + + for _ in 0..MAX_DATAGRAMS { + let Ok(peer) = peer(&mut input) else { + break; + }; + let Ok(length) = input.int_in_range(0..=MAX_DATAGRAM_LEN) else { + break; + }; + let Ok(packet) = input.bytes(length) else { + break; + }; + + // 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. + let _ = core.handle_datagram(peer, packet); + 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)) +} + +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)) +}