§13.3
Chapter 13 · Implementation Guidance and Interoperability Testing

§13.3Validating Stream and Frame Semantics Under Stress Conditions

RFC 9000RFC 9114

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.

Implementationunder testStress / fuzz harnessnegative testing — every MUST-levelerror in the spec is a test casePASS = correct error code · boundedmemory · no crash, hang, or corrupt statetruncated frame — varint Length runspast the dataCONNECTION_CLOSE ·FRAME_ENCODING_ERROR(0x07)open more streams thaninitial_max_streams allowsCONNECTION_CLOSE ·STREAM_LIMIT_ERROR (0x04)send bytes past the advertisedflow-control limitCONNECTION_CLOSE ·FLOW_CONTROL_ERROR (0x03)HTTP/3 DATA frame before anyHEADERSCONNECTION_CLOSE ·H3_FRAME_UNEXPECTED(0x0105)reset a stream, then change its finalsizeCONNECTION_CLOSE ·FINAL_SIZE_ERROR (0x06)
Fig. 13.3-1Negative testing against an implementation. A harness feeds malformed and adversarial inputs — a truncated frame, too many streams, data past the flow-control limit, DATA before HEADERS, a changed final size — and the correct response to each is a CONNECTION_CLOSE with a specific error code. A pass is the right code with bounded memory and no crash; the wrong code, a hang, or corruption is a failure.RFC 9000 §12.4; RFC 9114 §4.1

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_ERROR at the HTTP/3 layer, or QPACK_DECOMPRESSION_FAILED for 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 a max_streams above 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.

Note

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.

In practice

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.