From 40690cab53a4618ef1bdada1fc48856a16a057ce Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Thu, 27 Aug 2026 12:34:37 -0300 Subject: [PATCH] Add fuzz target for payjoin v2 URI parsing Covers the v2-specific fragment parameters (EX1, OH1, RK1) that the existing uri_deserialize_pjuri target never reaches. A custom mutator seeds the fuzzer with a valid v2 pj parameter so it reaches the v2 parsing paths from an empty corpus. 25% of mutations pass straight to fuzzer_mutate to keep the reject paths covered. --- fuzz/Cargo.toml | 6 + fuzz/fuzz_targets/uri/deserialize_pjuri_v2.rs | 104 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 fuzz/fuzz_targets/uri/deserialize_pjuri_v2.rs diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 7d8688f8d..21c3ecbd2 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -48,3 +48,9 @@ name = "url_arbitrary_url" path = "fuzz_targets/url/arbitrary_url.rs" doc = false bench = false + +[[bin]] +name = "uri_deserialize_pjuri_v2" +path = "fuzz_targets/uri/deserialize_pjuri_v2.rs" +doc = false +bench = false diff --git a/fuzz/fuzz_targets/uri/deserialize_pjuri_v2.rs b/fuzz/fuzz_targets/uri/deserialize_pjuri_v2.rs new file mode 100644 index 000000000..f73cda652 --- /dev/null +++ b/fuzz/fuzz_targets/uri/deserialize_pjuri_v2.rs @@ -0,0 +1,104 @@ +#![no_main] + +use libfuzzer_sys::{fuzz_mutator, fuzz_target, fuzzer_mutate}; +use payjoin::bitcoin::address::{Address, NetworkUnchecked}; +use payjoin::Uri; + +const SCHEME: &str = "bitcoin:"; + +/// One canonical address per script type, so the mutator keeps every type +/// the target inspects in rotation. +const ADDRESSES: [&str; 4] = [ + "12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", + "3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX", + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", +]; + +/// A valid v2 pj parameter with all three required fragment fields (EX1, +/// OH1, RK1). Without a bootstrapped default the fuzzer cannot construct +/// a checksummed bitcoin address or a valid bech32-encoded HPKE pubkey, +/// so it would never reach the v2-specific parsing paths. +const DEFAULT_QUERY: &str = "amount=1&pjos=0\ + &pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ\ + %23EX1C4UC6ES\ + -OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC\ + -RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV"; + +/// Share of mutations left to libFuzzer untouched. Everything the mutator +/// emits parses as a BIP 21 URI, so without a raw share the parser's reject +/// paths, and non-UTF-8 input, stop being covered at all. +const RAW_MUTATION_IN: u32 = 4; + +/// Split `bitcoin:
?`, substituting a canonical address +/// whenever the input does not carry one that parses. +/// +/// A bitcoin address is checksummed, so libFuzzer cannot construct one: +/// unlike a character-set check, which it learns from the comparisons it +/// traces, a checksum gives it no way to turn the constant it needs into +/// input bytes. Without help every input is rejected before the payjoin +/// parameters are ever read. Substituting an address here bootstraps the +/// target from an empty corpus, and re-emitting an intact one keeps +/// mutations from falling straight back out of the parser. +fn split_uri(input: &str, seed: u32) -> (&str, &str) { + let rest = input.strip_prefix(SCHEME).unwrap_or_default(); + let (address, query) = match rest.find('?') { + Some(i) => (&rest[..i], &rest[i + 1..]), + None => (rest, DEFAULT_QUERY), + }; + if address.parse::>().is_ok() { + (address, query) + } else { + (ADDRESSES[seed as usize % ADDRESSES.len()], query) + } +} + +// Spend the mutation budget on the query, where the payjoin parameters +// live, rather than on the address. +fuzz_mutator!(|data: &mut [u8], size: usize, max_size: usize, seed: u32| { + if seed.is_multiple_of(RAW_MUTATION_IN) { + return fuzzer_mutate(data, size, max_size); + } + let input = std::str::from_utf8(&data[..size]).unwrap_or_default(); + let (address, query) = split_uri(input, seed); + let prefix = format!("{SCHEME}{address}?"); + let mut buf = query.as_bytes().to_vec(); + let query_max = max_size.saturating_sub(prefix.len()); + if query_max == 0 { + return fuzzer_mutate(data, size, max_size); + } + let query_len = buf.len().min(query_max); + buf.resize(query_max, 0); + let query_len = fuzzer_mutate(&mut buf, query_len, query_max); + data[..prefix.len()].copy_from_slice(prefix.as_bytes()); + data[prefix.len()..prefix.len() + query_len].copy_from_slice(&buf[..query_len]); + prefix.len() + query_len +}); + +fn do_test(data: &[u8]) { + if let Ok(uri_str) = std::str::from_utf8(data) { + let pj_uri = match Uri::try_from(uri_str.to_string()) { + Ok(uri) => uri.assume_checked(), + Err(_) => return, + }; + let _ = pj_uri.address().is_spend_standard(); + let _ = pj_uri.label(); + let _ = pj_uri.message(); + let Ok(pj_extras) = pj_uri.check_pj_supported() else { return }; + let _ = pj_extras.extras().output_substitution(); + let _ = pj_extras.extras().endpoint(); + } +} + +fuzz_target!(|data| { + do_test(data); +}); + +#[cfg(test)] +mod tests { + #[test] + fn duplicate_crash() { + let data = b"\x00"; + super::do_test(&data[..]); + } +}