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_size of 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_body when 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:

  1. 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 an AlpnMatcher (Any, or a 1-2-protocol OneOf drawn from {h2, http/1.1, h3, foo}) and a synthesized cluster id;
  2. a PrereadConfig: inbound_proxy flag, max_bytes in 64..=16384, a fixed 3-second timeout, accept_wildcard flag;
  3. 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::Bytes under a monotonic injected clock, advances that clock without feeding bytes, or injects Input::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:

SeedDrives the core to
routed_exact_sniA single-record ClientHello for a.example.com fed whole → Routed via an Any route, content_offset = 0.
proxy_v2_prefixed_helloA 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_timeoutHalf of a valid ClientHello, then a Timeout before completion → Reject(Fragmented).
grease_heavy_hello_oneof_alpnA 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_junkA 5-byte record whose ContentType is application_data (23), not handshakeReject(NotTls).
malformed_record_oversizeA handshake-typed record declaring a length past the 2142^{14} RFC 8446 cap → Reject(MalformedRecord).
regression-sni-list-len-overflow-1A wire-declared server_name_list / extension length that overruns the container it sits in → Reject(MalformedHandshake), never a panic.
regression-sni-list-len-overflow-2Same crash class, a second lying-length variant.
regression-sni-list-len-overflow-3Same 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:

  1. 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>
    
  2. 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
    
  3. 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-regression entry is the canonical example).

5. Triage Workflow

If a finding is reproducible:

  1. Capture the failing input as a regression seed (see §4).
  2. 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.
  3. 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:

  1. Add .clusterfuzzlite/Dockerfile that:
    • bases on gcr.io/oss-fuzz-base/base-builder-rust;
    • copies the source tree into /src/sozu;
    • installs protoc (the workspace build prerequisite — see repo CLAUDE.md);
    • sets WORKDIR /src/sozu.
  2. Add .clusterfuzzlite/build.sh that runs cargo +nightly fuzz build for all four targets and copies the produced binaries into $OUT/.
  3. Add .github/workflows/clusterfuzzlite.yml that runs the google/clusterfuzzlite/actions/run_fuzzers@v1 action on pull_request and on a nightly cron, with a CPU/time budget of around 5 minutes per target on PR runs (longer on the cron).
  4. 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 fuzz job (§6.1), running all four targets for 300 s each on every push and pull request;
  • the local cargo +nightly fuzz run workflow documented in §3, run by reviewers when touching lib/src/protocol/mux/parser.rs, lib/src/protocol/mux/pkawa.rs, lib/src/protocol/mux/serializer.rs, lib/src/protocol/udp/, or lib/src/protocol/tcp_preread/;
  • the in-tree e2e/src/tests/fuzz_tests.rs ten-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 during cargo test, skipping gracefully when nightly / cargo-fuzz are absent.
  • lib/src/protocol/mux/parser.rs — the H2 frame parser the fuzz_frame_parser target drives.
  • lib/src/protocol/mux/pkawa.rs — in-tree HPACK consumer; the fuzz_hpack_decoder target verifies the underlying loona-hpack decoder 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 the fuzz_udp_flow target drives.
  • lib/src/protocol/tcp_preread/mod.rs — the sans-io TCP SNI-preread core the fuzz_tcp_clienthello target drives; lib/src/protocol/tcp_preread/parser.rs owns the ClientHello wire parsing it exercises.
  • .github/workflows/ci.yml — the dedicated fuzz job (nightly toolchain) that runs all four targets in CI; see §6.1.