Sōzu
July 13, 2026 · View on GitHub
fuzz/ is an out-of-workspace cargo-fuzz crate hosting libFuzzer harnesses
for the H2 wire surface, the sans-io UDP load-balancing core, and the
sans-io TCP SNI-preread core. The crate is intentionally outside the main
Cargo workspace (see [workspace] block at fuzz/Cargo.toml:16) so that
nightly sanitizer builds do not pull the rest of the workspace through
libFuzzer's build flags.
This document covers the layout, the four harnesses, how to run them locally, how to triage findings, how CI's dedicated nightly fuzz job exercises them, and what would additionally be required to wire the project into ClusterFuzzLite / OSS-Fuzz.
1. Crate Layout
fuzz/
├── Cargo.toml # cargo-fuzz crate manifest, edition 2021
├── Cargo.lock # pinned for reproducibility
├── fuzz_targets/
│ ├── fuzz_frame_parser.rs # H2 frame-codec fuzzer (RFC 9113)
│ ├── fuzz_hpack_decoder.rs # HPACK-decoder fuzzer (RFC 7541)
│ ├── fuzz_udp_flow.rs # sans-io UDP load-balancing core fuzzer
│ └── fuzz_tcp_clienthello.rs # sans-io TCP SNI-preread core fuzzer
├── corpus/ # tracked seed corpora
│ ├── fuzz_frame_parser/
│ │ ├── connection_preface
│ │ ├── data_frame
│ │ ├── goaway_frame
│ │ ├── headers_frame
│ │ ├── crash-d7a34a0d-padded-headers-underflow-regression
│ │ └── ...
│ ├── fuzz_hpack_decoder/
│ │ └── ...
│ └── fuzz_tcp_clienthello/
│ ├── routed_exact_sni
│ ├── proxy_v2_prefixed_hello
│ ├── truncated_hello_then_timeout
│ ├── grease_heavy_hello_oneof_alpn
│ ├── non_tls_junk
│ ├── malformed_record_oversize
│ └── ...
└── artifacts/ # crash artifacts, not committed
├── fuzz_frame_parser/
├── fuzz_hpack_decoder/
├── fuzz_udp_flow/
└── fuzz_tcp_clienthello/
The seed corpora under fuzz/corpus/ are committed to the repository on
purpose. Each named sample documents a specific frame shape (data_frame,
goaway_frame, …); each crash-* sample is a regression input that
previously triggered a crash and now must continue to be parsed cleanly
forever. Corpus growth during a fuzz run is normal; promote anything that
survives a successful run and meaningfully improves coverage to a named
sample under fuzz/corpus/fuzz_*/. Untracked corpus files generated by a
local fuzz run (the kind that show up as ?? in git status) should be
either reviewed for promotion or pruned with git clean -f fuzz/corpus/.
2. The Four Harnesses
2.1 fuzz_frame_parser
Source: fuzz/fuzz_targets/fuzz_frame_parser.rs.
Drives the H2 frame parser (sozu_lib::protocol::mux::parser) against
arbitrary input. Each call exercises:
- the 24-byte client connection preface (
parser::preface,fuzz_frame_parser.rs:15); - the 9-byte frame header at the configured
max_frame_sizeof 16 384 (parser::frame_header,fuzz_frame_parser.rs:20) — this is the value enforced for every listener and matches the H2 default; - the same header at
max_frame_size = 16_777_215(the protocol cap) to exercise the upper bound (fuzz_frame_parser.rs:26); - the corresponding
parser::frame_bodywhen a header parses successfully.
Bug class defended: the length-confusion family of CVEs where a malformed
length field could cause an under- or over-read of the body slice. The
in-tree ensure_frame_size! macro (lib/src/protocol/mux/parser.rs) was
extracted as part of the same hardening pass; the fuzzer is the regression
gate that keeps it honest.
2.2 fuzz_hpack_decoder
Source: fuzz/fuzz_targets/fuzz_hpack_decoder.rs.
Drives the loona-hpack decoder against arbitrary header-block fragments
under three dynamic-table profiles:
- default table size (
fuzz_hpack_decoder.rs:16); - 256-byte table to stress eviction (
fuzz_hpack_decoder.rs:27-29); - zero-byte table to force every entry to evict immediately
(
fuzz_hpack_decoder.rs:32-34).
Bug class defended: header-block oversize, incomplete-update, and table
resize edge cases. The decoder is the canonical RFC 7541 implementation
imported through loona-hpack = "0.4" (fuzz/Cargo.toml:12); the fuzzer
covers the worst-case shapes that the in-tree HPACK consumer
(lib/src/protocol/mux/pkawa.rs) routes into the decoder.
2.3 fuzz_udp_flow
Source: fuzz/fuzz_targets/fuzz_udp_flow.rs.
Drives UdpManager — the sans-io UDP
load-balancing core (issue #1273) — through an arbitrary sequence of
client/backend datagrams, control-plane reconfig events, backend
resolutions, and clock advances, fully draining poll_output after every
step. It also exercises SourceTupleExtractor::flow_key and the PPv2
dgram_header / prepend_dgram_header builders directly on arbitrary
bytes and addresses. The core is pure sans-io (no socket, no
Instant::now() on the datapath); the target injects a monotonic clock
advanced only by deltas parsed out of the input, so a given input always
produces the same run.
Invariants asserted beyond "never panic": live flow count never exceeds
the high-water mark of every max_flows cap ever set; the CloseFlow /
FlowCreated gauge never underflows; a final long clock advance reaps
every flow back to zero with no armed timer left (no fd / slab leak); the
2-tuple FlowKey form always normalises the port to zero; the PPv2 header
is never shorter than its 16-byte fixed prefix and prefixing accounts for
every byte exactly.
Bug class defended: flow-table admission/eviction races, gauge leaks, and
PPv2 datagram-framing bugs in the UDP load-balancing core. No named seed
corpus is committed for this target — fuzz/corpus/fuzz_udp_flow/ holds
only locally-grown, untracked libFuzzer corpus entries (see the
.gitignore pattern noted in §1).
2.4 fuzz_tcp_clienthello
Source: fuzz/fuzz_targets/fuzz_tcp_clienthello.rs.
Drives SniPrereadCore::handle_input
— the sans-io TCP-passthrough SNI-preread core (issue #1279) — entirely
through its public API (plus the already-public
sozu_lib::router::pattern_trie::TrieNode it reuses for routing). A
big-endian Reader over the fuzz input (mirroring fuzz_udp_flow.rs)
derives, in order:
- a route table of 1-4 entries, each an SNI key from a fixed pool
(
a.example.com,*.example.com,b.example.net,*.wild.example.org) mapped to anAlpnMatcher(Any, or a 1-2-protocolOneOfdrawn from{h2, http/1.1, h3, foo}) and a synthesized cluster id; - a
PrereadConfig:inbound_proxyflag,max_bytesin64..=16384, a fixed 3-second timeout,accept_wildcardflag; - a bounded (≤ 4096 iterations) step loop that either grows the
accumulated preread window by a length-prefixed chunk taken DIRECTLY
from the remaining fuzz bytes and feeds
Input::Bytesunder a monotonic injected clock, advances that clock without feeding bytes, or injectsInput::Timeout/Input::FrontClosed.
Invariants asserted beyond "never panic": the fed window is byte-identical
before and after every call; once a terminal Output (Routed / Reject)
is latched, every later call replays it identically and NeedMore can never
reappear; Routed::content_offset never exceeds the window it was derived
from; a NeedMore::deadline, once observed, never decreases across calls.
Bug class defended: parser panics / smuggling-shaped desyncs in the TLS record-layer + ClientHello reassembly (multi-record split, PROXY-v2 prefix stripping, GREASE-laden extension walks) that decide TCP passthrough routing without ever terminating TLS.
Seed corpus (fuzz/corpus/fuzz_tcp_clienthello/), each mapped to the state
it drives the core into:
| Seed | Drives the core to |
|---|---|
routed_exact_sni | A single-record ClientHello for a.example.com fed whole → Routed via an Any route, content_offset = 0. |
proxy_v2_prefixed_hello | A 28-byte PROXY-v2 IPv4 header fed alone (NeedMore), then the ClientHello appended → Routed with content_offset = 28 and proxy_source set from the PPv2 header. |
truncated_hello_then_timeout | Half of a valid ClientHello, then a Timeout before completion → Reject(Fragmented). |
grease_heavy_hello_oneof_alpn | A ClientHello with RFC 8701 GREASE extensions bracketing the server_name + alpn extensions, routed against a OneOf({"h2"}) entry → Routed (the extension walk skips GREASE by length, ALPN client-preference-order picks h2). |
non_tls_junk | A 5-byte record whose ContentType is application_data (23), not handshake → Reject(NotTls). |
malformed_record_oversize | A handshake-typed record declaring a length past the RFC 8446 cap → Reject(MalformedRecord). |
regression-sni-list-len-overflow-1 | A wire-declared server_name_list / extension length that overruns the container it sits in → Reject(MalformedHandshake), never a panic. |
regression-sni-list-len-overflow-2 | Same crash class, a second lying-length variant. |
regression-sni-list-len-overflow-3 | Same crash class, a third lying-length variant. |
These three are regressions for a debug_assert! that was placed before
the enforcing take on a declared list length and used to panic on
exactly this shape. The underlying bug class is unit-tested directly in
lib/src/protocol/tcp_preread/parser.rs as
sni_list_len_overflowing_extension_body_is_malformed_not_panic (whose doc
comment names it as the fuzz_tcp_clienthello regression) plus the
sibling-field cases sni_name_len_overflowing_list_is_malformed_not_panic,
alpn_list_len_overflowing_extension_body_is_malformed_not_panic, and
extension_len_overflowing_block_is_malformed_not_panic; each fix
reordered its check so an attacker-controlled length rejects cleanly
instead of panicking. The three corpus files are distinct fuzzer-found
byte sequences that triggered this bug class before the fix; this
document does not assert a 1:1 mapping between a given file and a specific
named test above.
3. Running Locally
Pre-requisites:
- a Rust nightly toolchain —
rustup toolchain install nightly; cargo install cargo-fuzz --locked.
Then from the repository root:
cd fuzz
cargo +nightly fuzz run fuzz_frame_parser
cargo +nightly fuzz run fuzz_hpack_decoder
cargo +nightly fuzz run fuzz_udp_flow
cargo +nightly fuzz run fuzz_tcp_clienthello
Each invocation runs forever until you stop it. To time-bound a run:
cargo +nightly fuzz run fuzz_frame_parser -- -max_total_time=60
Corpus growth is automatic — libFuzzer will append surviving inputs to
fuzz/corpus/fuzz_frame_parser/ while the run progresses.
The in-tree smoke check e2e/src/tests/fuzz_tests.rs runs each target for
ten seconds inside the standard cargo test flow when the nightly
toolchain and cargo-fuzz are available; when they are not, the test
logs a skip notice and returns cleanly so the rest of the e2e suite still
runs. The four wrappers are ordinary #[test] functions — not
#[ignore]d — so they already run as part of a plain
cargo test -p sozu-e2e. To run only them:
cargo test -p sozu-e2e fuzz
4. Reproducing a Regression
When a fuzz run trips a crash, libFuzzer drops a minimised input under
fuzz/artifacts/fuzz_*/crash-*. To replay it:
- Copy the artifact into the seed corpus so it stays in the repository:
cp fuzz/artifacts/fuzz_frame_parser/crash-<hash> \ fuzz/corpus/fuzz_frame_parser/crash-<hash>-<short-description> - Re-run the target — libFuzzer will replay every corpus entry on
start-up and surface the regression deterministically:
cd fuzz cargo +nightly fuzz run fuzz_frame_parser - Once the underlying defect is fixed, keep the renamed corpus file in
the tree as a permanent regression seed (the existing
crash-d7a34a0d-padded-headers-underflow-regressionentry is the canonical example).
5. Triage Workflow
If a finding is reproducible:
- Capture the failing input as a regression seed (see §4).
- Bring it to the maintainers listed in
.github/CODEOWNERS(@FlorentinDUBOIS @Wonshtrum @llenotre) via the active PR review or directly. They drive the security-disclosure process for the H2 stack. - If the finding is security-sensitive, do not file it as an open issue first — coordinate disclosure with the maintainers per the project's shared-state policy.
This document does not instruct readers to open issues directly: opening or closing GitHub issues is a shared-state action on this repository and is reserved for the maintainers.
6. OSS-Fuzz / ClusterFuzzLite Integration
6.1 Current state
ClusterFuzzLite itself is not wired up: there is no .clusterfuzzlite/
directory at the repository root and no
google/clusterfuzzlite/actions/run_fuzzers job. CI does, however, run a
dedicated fuzz job (.github/workflows/ci.yml, nightly Rust toolchain,
timeout-minutes: 45) on every push and pull request: it installs
cargo-fuzz and runs each of the four targets for -max_total_time=300
seconds, uploading fuzz/artifacts/ on failure. The four harnesses are
therefore exercised in three places: that CI job, local
cargo +nightly fuzz run (§3), and the e2e/src/tests/fuzz_tests.rs
ten-second smoke check documented above (skipped by test-name in the main
pipeline's Test sozu-e2e step, to avoid rebuilding fuzz/ under every
crypto-provider cache key — see the comment above that step in ci.yml).
6.2 Steps to integrate ClusterFuzzLite (per-PR continuous fuzzing)
Per the ClusterFuzzLite build-integration guide, the work is roughly:
- Add
.clusterfuzzlite/Dockerfilethat:- bases on
gcr.io/oss-fuzz-base/base-builder-rust; - copies the source tree into
/src/sozu; - installs
protoc(the workspace build prerequisite — see repoCLAUDE.md); - sets
WORKDIR /src/sozu.
- bases on
- Add
.clusterfuzzlite/build.shthat runscargo +nightly fuzz buildfor all four targets and copies the produced binaries into$OUT/. - Add
.github/workflows/clusterfuzzlite.ymlthat runs thegoogle/clusterfuzzlite/actions/run_fuzzers@v1action onpull_requestand on a nightly cron, with a CPU/time budget of around 5 minutes per target on PR runs (longer on the cron). - Optionally promote to upstream OSS-Fuzz following the
OSS-Fuzz acceptance criteria.
The Sōzu workspace is licensed AGPL-3.0 / LGPL-3.0 (see per-crate
Cargo.toml); the OSS-Fuzz project policy on copyleft licences should be re-checked before submitting an integration PR.
Until ClusterFuzzLite is added, the existing safety nets are:
- CI's dedicated
fuzzjob (§6.1), running all four targets for 300 s each on every push and pull request; - the local
cargo +nightly fuzz runworkflow documented in §3, run by reviewers when touchinglib/src/protocol/mux/parser.rs,lib/src/protocol/mux/pkawa.rs,lib/src/protocol/mux/serializer.rs,lib/src/protocol/udp/, orlib/src/protocol/tcp_preread/; - the in-tree
e2e/src/tests/fuzz_tests.rsten-second smoke check.
7. Cross-References
e2e/src/tests/fuzz_tests.rs— ordinary#[test]smoke wrappers (no#[ignore]) that exercise all four targets for a bounded time duringcargo test, skipping gracefully when nightly /cargo-fuzzare absent.lib/src/protocol/mux/parser.rs— the H2 frame parser thefuzz_frame_parsertarget drives.lib/src/protocol/mux/pkawa.rs— in-tree HPACK consumer; thefuzz_hpack_decodertarget verifies the underlyingloona-hpackdecoder it relies on.lib/src/protocol/mux/LIFECYCLE.md— context for the framing rules the parser enforces.lib/src/protocol/udp/manager.rs— the sans-io UDP load-balancing core thefuzz_udp_flowtarget drives.lib/src/protocol/tcp_preread/mod.rs— the sans-io TCP SNI-preread core thefuzz_tcp_clienthellotarget drives;lib/src/protocol/tcp_preread/parser.rsowns the ClientHello wire parsing it exercises..github/workflows/ci.yml— the dedicatedfuzzjob (nightly toolchain) that runs all four targets in CI; see §6.1.