Why protocol testing demands a sharper tool
Protocol specifications are written as precise instructions for engineers, and the IETF’s standardization process is designed to make those instructions as unambiguous as humanly possible. But even a spec that passes expert review can be implemented incorrectly. And when the implementation sits at the front door of a network handling live internet traffic, the gap between "close enough" and "correct" can become a security hole — request smuggling being the canonical example of what happens when a server misinterprets malformed input.
Cloudflare’s ingress proxy is exactly such a front door. Every HTTP request that reaches the Cloudflare network passes through it, and a surprising number of those requests are malformed or non-compliant in some way. Some of that is accidental; some of it is deliberate probing. Either way, the proxy must be robust enough to handle traffic that does not follow RFC rules while remaining standards-compliant itself.
The Protocols team that owns this proxy participates directly in IETF standardization and has built a wide range of services on quiche, Cloudflare’s QUIC implementation — from iCloud Private Relay via MASQUE proxying to WARP’s transition from WireGuard. Across all of those use cases, the need for rigorous protocol testing is constant. And when it comes to HTTP/3, the team found that existing tools were not always enough. The result is h3i: a command line tool and Rust library for low-level HTTP/3 testing, released as free and open source under the quiche project.
What h3i does
h3i is built for the kind of testing that ordinary HTTP client libraries cannot support. Libraries like Python’s Requests exist to make correct, conventional HTTP exchanges easy. That is exactly what most developers want — but for protocol hardening, it is also the limitation. A library that follows the RFC rules will refuse to let you do the wrong thing, even when doing the wrong thing is precisely what you need to exercise in order to verify that your server handles it safely.
With HTTP/2, Cloudflare’s team hit this wall years ago. They relied on the lower-level h2 library for frame-by-frame control, but sometimes even that was not enough. For certain edge cases, the only option was to handcraft frames in a hex editor, save them as binary, and replay them through a tool like OpenSSL s_client. It worked, but it was slow and awkward.
HTTP/3 needed the same class of testing — and more. When the team started HTTP/3 work in 2018, suitable client implementations were scarce, and the fast-moving specifications made it hard to keep external tools in sync. The team initially tested with quiche-client and a custom http3_test tool, later adding tests based on the aioquic Python library. But across the entire effort, the ongoing need was for a tool that offers two things at once:
- Convenient, scriptable control over individual HTTP/3 frames and QUIC streams, and
- The ability to deliberately violate protocol rules without a library stepping in to prevent it.
h3i is designed to fill that gap. It allows testers to craft and send exact sequences of frames over a QUIC connection, inspect the responses at the frame level, and construct the kinds of pathological exchanges that real-world attackers might attempt. The tool is usable both as a binary for ad-hoc debugging and as a Rust library for building automated tests into a CI pipeline.
The architecture underneath: QUIC and HTTP/3 essentials
Before getting into usage patterns, a quick recap of the layers h3i operates at. HTTP/3 maps HTTP semantics onto QUIC, a transport protocol that offers performance benefits over TCP/TLS through a more efficient handshake and built-in stream multiplexing that avoids head-of-line blocking. The IETF finalized QUIC version 1 and HTTP/3 in RFC 9000 and companion documents in 2021; Cloudflare shipped support for both immediately.
HTTP message semantics are version-agnostic, defined in RFC 9110. Each HTTP version then defines a wire format. With HTTP/3, messages are split into binary frames that travel over QUIC streams. Key frame types are:
HEADERS— carries HTTP metadata (method, path, status code, header fields) as QPACK-compressed data.DATA— carries the message content, i.e., the body.
QUIC streams are ordered, reliable byte streams, each with an identifier unique within the connection. The ID’s two least significant bits denote the stream type: client-initiated bi-directional streams, server-initiated bi-directional, client-initiated uni-directional, and server-initiated uni-directional. For the simplest HTTP/3 request/response cycle, a client opens the first available client-initiated bidirectional stream (IDs 0, 4, 8, 12, …), sends a HEADERS frame with the request and the stream’s FIN bit set, then the server responds on the same stream with its own HEADERS, DATA, and FIN. QUIC underneath is splitting the stream into STREAM frames carried in packets over UDP, handling loss detection and recovery to make the byte stream reliable.
The need for abnormal frame-level testing
The Protocols team counts on a diverse set of automated tools to exercise the ingress proxy with worst-case traffic, much as a bouncer screens arrivals at a venue door. With HTTP/2, production experience taught hard lessons about the attacks enabled by frame interactions that normal client usage never explores. The denial-of-service vectors Netflix identified in 2019 and the HTTP/2 Rapid Reset attacks in 2023 both illustrate that security questions tend to live in the interactions between frame types, not in the happy path.
HTTP/3, by sharing several architectural concepts with HTTP/2, inherits the same class of concerns — while adding its own QUIC-layer complexities. Thorough hardening requires the ability to construct exchanges that conventional clients cannot produce. h3i provides that capability in an approachable form.
It should be noted here that protocol hardening extends far beyond what h3i itself encompasses. Cloudflare’s ingress proxies are validated by a substantial battery of tests, and quiche itself carries over 500 integration tests. h3i is designed to fill the niches that other tools do not comfortably cover — the hex-editor-and-replay territory where byte-level precision matters more than spec compliance.
Command line and library interface
Command line basics
The h3i binary is a practical starting point. It accepts a URL argument and, by default, attempts to establish a QUIC connection and HTTP/3 session to the host, then behaves like a minimal HTTP/3 client — issuing a GET request and reporting the response. That happy path is covered by numerous other tools, though, and h3i’s real value shows in the flags you add to reshape that default flow.

The library surface
As a Rust library, h3i exposes the same underlying machinery that powers the CLI. Test code can construct requests at the frame or stream level, drive connections step by step, and inspect every server response frame type with explicit visibility. This enables more advanced, lower-level test scenarios that can bend or break protocol rules.
The Python wrapper
The h3i package on crates.io is the Rust artifact, but continuous integration environments may find the pyh3i Python binding more convenient. That wrapper follows the maturin conventions for Rust/Python bindings. While h3i offers both binary and library modes of use, pyh3i is the library in all cases — there is no CLI entry point exposed to Python. The package can be used directly from a test suite without writing any Rust code.
Testing HTTP/3 edge cases without the plumbing
Cloudflare’s existing HTTP/3 test suite has good coverage, but the tools behind it are a patchwork. Some libraries, like aioquic, include safety guards that block intentionally invalid behavior, forcing engineers to patch the library just to write a negative test. The result: developing what should be a simple test often requires deep protocol expertise and hours of debugging.
What should be easy to express is something like a violation of the control stream rules in RFC 9114 Section 6.2.1. That section requires that each peer opens exactly one control stream and sends its SETTINGS frame first. From that single paragraph, a whole matrix of tests follows:
- Send a non-SETTINGS frame as the first frame on the control stream.
- Open two control streams.
- Open a control stream and close it with a FIN bit.
- Open a control stream and reset it with a QUIC RESET_STREAM frame.
- Wait for the peer’s control stream and request a reset via QUIC STOP_SENDING.
Each of these should cause a well-implemented remote peer to terminate the connection, which is why no normal client or server would ever do them. Test tooling, however, must be able to. And running such tests against the integrated application layer, not just the isolated protocol library, matters: failures in a lower layer easily cascade upward, as seen with the HTTP/2 Rapid Reset attacks that affected Cloudflare’s internal service-to-service traffic.
To lower the bar for this kind of testing, Cloudflare is open sourcing h3i, a command-line tool and Rust library for low-level HTTP/3 experimentation. The current implementation is a client that exercises servers; a reverse mode, where a server behaves erratically to test clients, is possible future work. It is not a production client and it is not built for performance testing.
The h3i command line tool
The h3i CLI is built for quick, ad-hoc debugging. It behaves a bit like curl, but with direct control over HTTP/3 internals. Instead of writing code, the user works through an interactive prompt that guides the composition of HTTP/3 actions: sending frames, opening and closing streams, or waiting for server data.
A minimal session against https://cloudflare-quic.com looks like this:
- Install the binary with
cargo install h3i, or clone the quiche repository and run from theh3idirectory withcargo run. Both assume familiarity with Cargo. - Launch with the target host and optionally a port (default 443):
cargo run cloudflare-quic.com. - Queue up a sequence of actions at the interactive prompt. Tab completion is supported. The
headersaction, for example, walks through each field in a HEADERS frame, prefilling the RFC 9114 mandatory pseudo-header fields. To omit those, useheaders_no_pseudoinstead. - Select
committo establish the QUIC connection and execute the queued actions in order. - By default, h3i prints a small amount of server frame metadata. Setting the
RUST_LOGenvironment variable todebugortraceshows more detail.
The full set of prompt options is in the project readme.
Replay with qlog
Once an interesting sequence of actions is found, rerunning it manually each time gets old. h3i addresses this by recording every session to a log file by default, the path of which is printed at startup. The log format is based on qlog, the IETF’s in-progress standard for protocol logging.
{"qlog_version":"0.3","qlog_format":"JSON-SEQ","title":"h3i","description":"h3i","trace":{"vantage_point":{"type":"client"},"title":"h3i","description":"h3i","configuration":{"time_offset":0.0}}}
{
"time": 0.172783,
"name": "http:frame_created",
"data": {
"stream_id": 0,
"frame": {
"frame_type": "headers",
"headers": [
{
"name": ":method",
"value": "GET"
},
{
"name": ":authority",
"value": "cloudflare-quic.com"
},
{
"name": ":path",
"value": "/"
},
{
"name": ":scheme",
"value": "https"
},
{
"name": "user-agent",
"value": "h3i"
}
]
}
},
"fin_stream": true
}
Replay is done with the --qlog-input option, which allows changing the target host and port without altering the recorded actions. Because most servers validate the :authority pseudo-header or Host header in a HEADERS frame, the --replay-host-override flag can rewrite those fields on the fly. Since qlog files are plain JSON-SEQ text, they can also be written by hand.
Library use: a content-length mismatch example
The interactive CLI is convenient, but repeatable tests belong in code. The h3i Rust library supports that. One provided example implements a test that RFC 9114 Section 4.1.2 explicitly calls out as malformed: a Content-Length header that does not equal the length of the actual DATA frame payload. This scenario is a known vector for desynchronization attacks, especially when an intermediary translates HTTP/3 into HTTP/1.1.
The example client sends a Content-Length of 5 with a body of only 4 bytes ("test"), then waits for the server response and closes the connection with a QUIC CONNECTION_CLOSE frame. A compliant server must treat the mismatch as a stream error of type H3_MESSAGE_ERROR.
For low-level work, seeing the encrypted traffic on the wire can also help. QUIC prevents direct inspection, but setting the SSLKEYLOG environment variable captures session keys, letting tools like Wireshark decrypt and dissect the exchange. The typical flow from a clone of the quiche repo sounds like this:
cd quiche/h3i
SSLKEYLOGFILE="h3i-example.keys" cargo run --example content_length_mismatch
The decrypted capture shows the expected handshake, request, response, and orderly closure.

Inside the example client
The bundled example is a compact binary with a main() entry point that exercises a malformed-content scenario against a target server. The setup starts with an h3i configuration:
let config = Config::new()
.with_host_port("cloudflare-quic.com".to_string())
.with_idle_timeout(2000)
.build()
.unwrap();
The idle timeout is a QUIC mechanism that tells each endpoint when to close a connection that has been inactive. Without it, endpoints could linger indefinitely waiting for a peer that never closes. h3i defaults to 30 seconds, which is too generous for most tests, so the example sets it to 2 seconds.
Next, the request headers are defined and QPACK-encoded so they are ready for a HEADERS frame. h3i ships a send_headers_frame helper that handles this encoding automatically, but the example does it manually to make each step explicit:
let headers = vec![
Header::new(b":method", b"POST"),
Header::new(b":scheme", b"https"),
Header::new(b":authority", b"cloudflare-quic.com"),
Header::new(b":path", b"/"),
// We say that we're going to send a body with 5 bytes...
Header::new(b"content-length", b"5"),
];
let header_block = encode_header_block(&headers).unwrap();
The actions to execute are then assembled in order: send HEADERS, send a DATA frame that is too short, wait for the server's HEADERS response, and close the connection.
let actions = vec![
Action::SendHeadersFrame {
stream_id: STREAM_ID,
fin_stream: false,
headers,
frame: Frame::Headers { header_block },
},
Action::SendFrame {
stream_id: STREAM_ID,
fin_stream: true,
frame: Frame::Data {
// ...but, in actuality, we only send 4 bytes. This should yield a
// 400 Bad Request response from an RFC-compliant
// server: https://datatracker.ietf.org/doc/html/rfc9114#section-4.1.2-3
payload: b"test".to_vec(),
},
},
Action::Wait {
wait_type: WaitType::StreamEvent(StreamEvent {
stream_id: STREAM_ID,
event_type: StreamEventType::Headers,
}),
},
Action::ConnectionClose {
error: quiche::ConnectionError {
is_app: true,
error_code: quiche::h3::WireErrorCode::NoError as u64,
reason: vec![],
},
},
];
connect() drives the whole sequence—it establishes the QUIC connection, runs the action list, and collects the result into a ConnectionSummary.
let summary =
sync_client::connect(config, &actions).expect("connection failed");
println!(
"=== received connection summary! ===\n\n{}",
serde_json::to_string_pretty(&summary).unwrap_or_else(|e| e.to_string())
);
ConnectionSummary exposes what h3i observed: the frames received on each stream, the reason the connection closed, and connection statistics. The example prints this summary, but it can also be inspected programmatically, which is how Cloudflare wires it into internal automation tests.
Running the example yields output similar to this:
=== received connection summary! ===
{
"stream_map": {
"0": [
{
"UNKNOWN": {
"raw_type": 2471591231244749708,
"payload": ""
}
},
{
"UNKNOWN": {
"raw_type": 2031803309763646295,
"payload": "4752454153452069732074686520776f7264"
}
},
{
"enriched_headers": {
"header_block_len": 75,
"headers": [
{
"name": ":status",
"value": "400"
},
{
"name": "server",
"value": "cloudflare"
},
{
"name": "date",
"value": "Sat, 07 Dec 2024 00:34:12 GMT"
},
{
"name": "content-type",
"value": "text/html"
},
{
"name": "content-length",
"value": "155"
},
{
"name": "cf-ray",
"value": "8ee06dbe2923fa17-ORD"
}
]
}
},
{
"DATA": {
"payload_len": 104
}
},
{
"DATA": {
"payload_len": 51
}
}
]
},
"stats": {
"recv": 10,
"sent": 5,
"lost": 0,
"retrans": 0,
"sent_bytes": 1712,
"recv_bytes": 4178,
"lost_bytes": 0,
"stream_retrans_bytes": 0,
"paths_count": 1,
"reset_stream_count_local": 0,
"stopped_stream_count_local": 0,
"reset_stream_count_remote": 0,
"stopped_stream_count_remote": 0,
"path_challenge_rx_count": 0
},
"path_stats": [
{
"local_addr": "0.0.0.0:64418",
"peer_addr": "104.18.29.7:443",
"active": true,
"recv": 10,
"sent": 5,
"lost": 0,
"retrans": 0,
"rtt": 0.008140072,
"min_rtt": 0.004645536,
"rttvar": 0.004238173,
"cwnd": 13500,
"sent_bytes": 1712,
"recv_bytes": 4178,
"lost_bytes": 0,
"stream_retrans_bytes": 0,
"pmtu": 1350,
"delivery_rate": 247720
}
],
"error": {
"local_error": {
"is_app": true,
"error_code": 256,
"reason": ""
},
"timed_out": false
}
}
The StreamMap records every frame received on each stream. In this run, stream 0 carried five frames: two UNKNOWN frames, one EnrichedHeaders frame, and two DATA frames.
The UNKNOWN frames are extension frames h3i does not recognize—the server is sending GREASE frames to verify that the client tolerates unexpected input as required by RFC 9114. The EnrichedHeaders frame is an HTTP/3 HEADERS frame with convenience helpers, such as a method to extract the response status code; here, the server returned 400 as expected. The DATA frames carry the response body, which is the HTML for Cloudflare's Bad Request page. The raw body bytes are deliberately omitted from ConnectionSummary because they may not be safely representable as text; encoding them as base64 or hex is a possible future enhancement for tests that must validate response content.
Building tests around h3i
h3i is designed to slot into automated testing pipelines. The example above can be adapted to a range of integration test setups. Cloudflare's Protocols team had previously accumulated three different HTTP/3 testing frameworks, none of which offered much flexibility. Over the past year, h3i was built alongside a reimplementation of the ingress proxy test suite in Rust. That migration improved coverage with tests that were previously impossible and surfaced issues in the old tests, particularly edge cases where the original code's behavior was unclear.
Interop testing and the path forward
The idea of pitting protocol implementations against each other dates back to Jon Postel's RFC 1025 from 1987, which described "bake offs"—focused sessions bringing together every known implementation to test tricky cases and validate correctness against the spec. That concept remains relevant. The QUIC Working Group leaned heavily on interoperability testing during standardization, starting with manual, script-assisted sessions and later adopting Marten Seemann's QUIC Interop Runner for automated, regularly scheduled testing with rendered results.

HTTP/3 interop testing is less mature. Tools like Kazu Yamamoto's h3spec (written in Haskell) validate conformance, but there is no comparable continuous integration harness that collects and renders results across implementations. h3i overlaps with h3spec in scope, but h3i focuses on framework capabilities rather than shipping a corpus of assertions. Cloudflare's preference for Rust—several teams are moving to Rust-based proxies—makes a consistent ecosystem attractive for developer velocity.
There is room for collaboration across the QUIC and HTTP projects. h3i could serve as the foundation for a bake-off or interop runner, and a community-owned collection of test cases could be adapted to whatever tooling is most appropriate. That idea was raised at HTTP Workshop 2024 by Mohammed Al-Sahaf, and new testing directions continue to be explored.
Protocol testing raises security considerations. Suspected vulnerabilities in an IETF protocol itself should follow the IETF's reporting guidance. Implementation flaws in products, open source projects, or services should go directly to the responsible party, which often publishes contact details; Cloudflare's quiche security policy, for example, lives in the GitHub repository's Security tab.
Status and outlook
h3i is intentionally a limited test client, but the framework is meant to be extended. Low-level HTTP/2 support is a planned addition. The design accommodates manual ad-hoc testing, native Rust tests, and conformance testbenches in scripting languages. Migrating Cloudflare's internal test tools to a single, maintainable client has already paid off.
With h3i's capabilities in hand, consider how you might write tests that exercise a server's HTTP/3 control streams. Contributions, feedback, and ideas are welcome in the quiche GitHub repository as issues or pull requests.




