HPACK

http2-hpack is Cruft's HPACK implementation, the HTTP/2 header compression that indexes headers into a shared static and dynamic table. It bounds the decode side against the HPACK bomb with a running cap on decoded header-list size, a clamp on dynamic-table growth, and a varint-overflow guard.

HTTP/2 multiplexes and compresses. That header compression is HPACK: a shared dictionary plus back-references, so :authority: www.example.com travels as one byte the second time. In Node the h2 layer under http2 has been decompressing hostile header blocks in production for years. The question underneath it: what stops a client from sending 2 KB of indexed references that each expand a large stored value, so a tiny frame explodes into hundreds of megabytes of Vec? That is the HPACK bomb, the same shape as a zip bomb. Somebody had to write the cap. In Cruft, you can read where it is.

Alpha, do not deploy. This page documents http2-hpack (crate rusty-http2-hpack) at Cruft 0.0.10. It implements the HPACK layer that h2 provides for Node's http2. It has not been externally audited and has near-zero interop testing against real h2/nghttp2 peers. The DoS bounds below are exercised by the crate's own tests; interop breadth, Huffman hardening, and encode-side compression quality are not yet proven. Do not rely on it in production or against adversarial input.

HPACK is a table lookup protocol

HPACK is a table lookup protocol, distinct from a stream codec like gzip: gzip compresses an arbitrary byte sequence, while HPACK indexes headers into shared tables. There are two tables. The static table is 61 fixed entries baked into the spec (RFC 7541 Appendix A): :method: GET, :status: 200, and so on. The dynamic table is a per-connection FIFO the client fills as it goes, and both sides must stay byte-for-byte in sync or every subsequent header decodes wrong.

http2-hpack implements both. STATIC_TABLE is the 61-entry constant. DynamicTable is a VecDeque with size-based eviction, entry cost computed as name.len() + value.len() + 32 per RFC 7541 4.1 (entry_size). The combined index space (static first, then dynamic) resolves in DynamicTable::get. The RFC 7541 C.2.1 literal-with-indexing and C.2.3 indexed vectors decode correctly and land the new entry in the dynamic table at combined index 62; both are exercised by round-trip tests.

The integer that never ends

HPACK integers use a continuation encoding (RFC 7541 5.1): an n-bit prefix, then 7 bits per following octet with a high continuation bit. A naive decoder shifts each octet into an accumulator forever. Feed it a run of 0xFF octets and the shift count runs past 64, wrapping or looping. That is the varint-overflow surface on the decode side.

decode_integer guards it: after each continuation octet it checks the shift count and returns None once it exceeds 63, so an over-long integer is rejected as malformed rather than overflowing u64. The bound exists and is code-reachable, and the round-trip is exercised (integer_round_trips). One edge remains: there is no dedicated negative test that feeds a deliberately over-long integer and asserts the guard fires, so the guard is present but not yet exercised by an adversarial vector.

The bomb lives in the table

Here is the mental model to eliminate: that a header parser is safe once each field parses. It is not. The HPACK bomb needs no malformed field. Every field is well-formed. The attack: insert one large value into the connection-wide dynamic table via literal-with-incremental-indexing, then send a header block of repeated one or two byte indexed references, each of which clones that large entry into the output list. A few KB in, hundreds of MB out. A second lever compounds it: the wire-driven dynamic-table-size update (RFC 7541 6.3) can raise the table ceiling to an attacker-chosen value, removing eviction pressure so table memory is bounded only by bytes received.

That is closed now. The fix added a bounded decode entry point:

  • decode_header_block_limited takes a DecodeLimits: max_header_list_size and max_dynamic_table_size (defaults 65536 and 4096).
  • A running decoded-size accountant. Every emitted header passes through checked_header_list_size, which uses checked_add (so the accounting itself cannot overflow) and returns DecodeError::HeaderListTooLarge the moment the cumulative list crosses the cap. This bounds the expansion regardless of how cheap the references are on the wire.
  • A clamp on the 6.3 size update: an update whose value exceeds limits.max_dynamic_table_size returns DecodeError::DynamicTableSizeUpdateTooLarge, satisfying the RFC 7541 6.3 requirement that the update not exceed the decoder-advertised maximum.

Both defenses have default (non-ignored) negative tests. decode_rejects_header_list_expansion_past_limit drives a block past a 40-byte cap and asserts HeaderListTooLarge. decode_rejects_dynamic_table_size_update_above_advertised_limit sends an 8192 update against a 4096 advertised max and asserts DynamicTableSizeUpdateTooLarge. The unbounded legacy decode_header_block still exists but is a thin wrapper that calls the limited path with DecodeLimits::default(), so it is bounded too, and http2-conn uses the limited API for inbound request headers.

What it does not do

  • Huffman is partial, and labelled as such. The huffman module implements the canonical Appendix B code for encode and decode, with the RFC 7541 C.4.1 www.example.com vector as a round-trip case (huffman_round_trips_and_rfc_vector). Real curl/nghttp clients Huffman-encode strings, so this path matters. It is not a hardened table: treat its behavior on malformed Huffman padding as unproven.
  • Encoding is deliberately minimal. encode_header_block emits indexed static-table hits and plain literals with no Huffman and no incremental indexing on the encode side. It is a correct-but-naive encoder, not a compression-competitive one. h2 picks Huffman and indexing to minimize bytes; this does not.
  • No interop/conformance suite. There is no h2spec lane, no fuzz target over the decoder, and no differential against h2 or nghttp2. The tests are the RFC example vectors plus the DoS negatives. Default tests cover HPACK expansion and the dynamic-table-size refusal; h2spec, fuzz, and differential expansion are a high-value follow-on, not a shipped guarantee.

Limitations

The maturity baseline is h2: years of production traffic, an established fuzz corpus, and interop with every HTTP/2 client in existence. http2-hpack has none of that history. What it does have today is the specific set of DoS bounds a first adopter would otherwise get burned by: the varint overflow guard, the cumulative header-list cap, and the dynamic-table-size clamp, each reachable from the decode path http2-conn actually uses, and the last two backed by default negative tests. That is a real and narrow claim, and a scoped one: this is not a blanket "HPACK-safe" guarantee. The three named HPACK DoS classes have present, tested bounds; everything about interop breadth, Huffman hardening, and encode-side compression quality is unproven.