Learning objective. Learn to validate a QUIC/HTTP-3 stack the way an adversary would: feed it malformed frames, limit violations, illegal state transitions, and resource-exhaustion attempts, and confirm it responds with the correct error code (bounded, no crash) rather than accepting invalid state.
Correct-on-valid is not enough #
An implementation that handles well-formed traffic perfectly can still be badly broken, because the wire is hostile: peers send truncated frames, exceed the limits you advertised, and drive streams into illegal states — sometimes by bug, sometimes by malice. The specs are written for this: they are full of MUST-level error conditions, and each one is a promise that a conformant stack detects the violation and closes the connection with a specific error code. So the validation principle is blunt: every MUST-error in the spec is a test case, and passing means the stack produces the right error code with bounded resources: never a crash, hang, or silently corrupted state.
The categories of stress #
Robustness testing falls into a handful of families, each mapping to a class of MUST-error:
- Malformed encoding. Truncated varints, a frame whose Length runs past the data, an unparseable
QPACK instruction. These are
FRAME_ENCODING_ERROR(0x07, [RFC 9000 §12.4]) at the QUIC layer,H3_FRAME_ERRORat the HTTP/3 layer, orQPACK_DECOMPRESSION_FAILEDfor a bad field section (§6.5). The stack must reject them, not read past a buffer. - Limit violations. Opening more streams than
initial_max_streams(STREAM_LIMIT_ERROR,0x04), sending past a flow-control limit (FLOW_CONTROL_ERROR,0x03, §7.2), holding more connection IDs than advertised (CONNECTION_ID_LIMIT_ERROR,0x09), or amax_streamsabove 2^60 ([RFC 9000 §4.6]). Every advertised limit is something a peer will try to exceed. - State-machine violations. Sending stream data after a reset, changing a stream's final size
after it was set (
FINAL_SIZE_ERROR,0x06, [RFC 9000 §4.5]), a frame after CONNECTION_CLOSE, or DATA before HEADERS on a request stream (H3_FRAME_UNEXPECTED, §5.4). The stream and connection state machines must reject illegal transitions. - Resource exhaustion. A flood of streams, a header section larger than
SETTINGS_MAX_FIELD_SECTION_SIZE, deliberately blocked QPACK streams (§6.3), or held-open streams that pin memory. The stack must bound what any peer can make it allocate. The flow-control and blocked-stream limits exist precisely to cap this (§9.4). - Recovery stress. Reordering beyond the packet threshold, duplicated packets, large packet-number gaps, and induced loss (Chapter 3, §8.2). Correct behavior is that the connection makes progress and never mis-declares state. The §3.5 loss-recovery lab is a small version of this.
Fuzzing and boundary conditions #
Two techniques find what hand-written tests miss:
- Fuzzing. Feed randomly mutated packets and frames and assert the process never crashes, hangs, or leaks. Where the input is a defined violation, assert that the right error code comes back. QUIC's encryption makes network fuzzing awkward, so the highest-value fuzzing is at the frame-parsing layer, below decryption, where a mutated byte string exercises the varint and frame parsers directly (the QPACK codec harness of §6.5 is exactly this shape).
- Boundary conditions. The edges are where parsers break: zero-length frames and datagrams (both legal, [RFC 9297 §2.1]), a varint at its maximum width, an empty header section, a stream opened and immediately FIN'd, the largest legal stream ID. Each should be handled cleanly, neither rejected as malformed nor mishandled as a special case.
Worked example: a fuzzer finds a length bug #
A frame-layer fuzzer flips bytes in a captured HTTP/3 exchange and replays them into the server's parser.
Most mutations are caught: a corrupted QPACK section yields QPACK_DECOMPRESSION_FAILED, a bad frame
type in the wrong place yields H3_FRAME_UNEXPECTED. But one mutation sets a DATA frame's Length varint
to a value larger than the bytes actually present, and the parser, trusting the length, reads past the
buffer and crashes. The spec is clear this is a FRAME_ENCODING_ERROR ([RFC 9000 §12.4]). The parser
should have validated the Length against the available data and closed the connection. The fix is a bounds
check; the finding came only from feeding deliberately invalid input. No amount of valid-traffic testing
would have surfaced it, which is the whole argument for negative testing.
There is an important difference between the two error scopes under stress (§5.4). A malformed frame or a limit violation is usually a connection error: the whole connection is closed, because the peer has proven it cannot be trusted to frame correctly. But an application-level problem with a single request (an oversized header section, a request the server won't process) should be a stream error (RESET_STREAM / a 4xx), leaving the connection up for other requests. A stack that closes the whole connection for a single bad request, or that limps along after a framing violation that should have been fatal, has the scope wrong in one direction or the other. Stress testing is how you find which.
editorial Turn the spec's MUST-errors into an executable test matrix: one case per error code, asserting the code and that the process stays bounded and alive. Run a frame-layer fuzzer continuously in CI (it is cheap and finds the length/varint/state bugs that valid traffic never will) and keep a corpus of the malformed inputs it has caught as regression tests. Watch specifically for the two failure shapes that matter most: a crash or hang on malformed input (a denial- of-service vector), and silent acceptance of an invalid state (a correctness and security hole). Both are invisible to functional tests and both are exactly what an attacker probes for.
Takeaways #
Validating a stack means proving it survives hostile input: malformed encodings, limit violations, illegal state transitions, and resource-exhaustion attempts must each produce the specific error code the spec requires, with bounded memory and no crash, hang, or corruption. Every MUST-error is a test case; fuzzing at the frame-parsing layer and probing boundary conditions find what hand-written tests miss; and getting the error scope right (connection vs stream) is part of correctness. The security-specific slice of this (handshake, keying, and anti-abuse validation) is §13.4.