Skip to content

QPACK: RFC 9204 static-table compliance, decode robustness, and cleanup#13260

Draft
phongn wants to merge 6 commits into
apache:masterfrom
phongn:qpack-cleanup
Draft

QPACK: RFC 9204 static-table compliance, decode robustness, and cleanup#13260
phongn wants to merge 6 commits into
apache:masterfrom
phongn:qpack-cleanup

Conversation

@phongn

@phongn phongn commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

QPACK: RFC 9204 static-table compliance, decode robustness, and cleanup

Summary

A set of correctness and cleanup changes to the home-grown HTTP/3 QPACK implementation (src/proxy/http3/QPACK.cc, shared src/proxy/hdrs/XPACK.cc). The headline is an RFC 9204 static-table compliance fix (an interop bug); the rest hardens the decoder against malformed input, refuses a configuration we don't actually implement, and speeds up the static-table lookup. Each is a separate commit so they can be reviewed — or dropped — independently.

Let me know if this is too much for one PR, and it can be split up.

What's in here

1. Restore the RFC 9204 static table (drop zstd entries) — please review closely.
The QPACK static table (RFC 9204 Appendix A) is normative: its 99 indices are wire values shared with every peer and must not be reordered or extended. #12201 ("Add Zstandard compression support") inserted a 100th entry (content-encoding: zstd) into the middle of the table and appended zstd to entry 31's value. The insertion shifts RFC indices 42–98 to 43–99, so a standard peer mis-resolves every static reference at or above 42 (content-type variants, the second :status block, user-agent, x-frame-options, …) in both directions, and content-encoding: zstd itself decodes as br on a compliant client. This restores the table to RFC 9204 exactly. zstd content coding is unaffected — it is still conveyed as a literal field, which is how every compliant QPACK implementation encodes values absent from the static table. (This reverts the static-table portion of #12201; cc: @JakeChampion)

2. Add an RFC 9204 static-table conformance test.
Decodes header blocks referencing static indices spanning the shifted range (31, 42, 52, 71, 98) and asserts each yields the exact RFC name and value. Fails if the table is ever reordered or extended again. (Kept as a separate commit from the fix above so the two can be dropped together if needed.)

3. Fix header-prefix decode bounds and validation.
The Required Insert Count / Delta Base prefix decoding had three defects on malformed input: it read an uninitialized value and used && instead of || (so a failed integer decode usually wasn't caught, then advanced the cursor by a negative return); the Delta Base bound used an inverted comparison; and remain_len was never updated as the cursor advanced, so per-instruction decoders saw an end pointer past the real buffer (a potential over-read on a crafted length). Now decodes against a fixed end, initializes the prefix integers, rejects on || value > 0xFFFF, and recomputes the remaining length per instruction.

4. Reject dynamic references when no dynamic table exists.
A header block with a non-zero Required Insert Count references the dynamic table. ATS only stores dynamic entries once a non-zero table capacity is negotiated (and currently always advertises zero), so such a reference can never be satisfied — the decoder would queue the stream as blocked forever. Fail the decode when the table capacity is zero instead. Includes a test.

5. Refuse unimplemented QPACK dynamic-table configuration.
The dynamic table is not implemented (XpackDynamicTable never stores entries — capacity is fixed at zero and never raised). Advertising a non-zero header_table_size or qpack_blocked_streams would tell a peer it may insert entries our decoder silently drops and then reference them, breaking decoding. Fail at config load if either is set non-zero, rather than silently misbehaving on the wire.

6. Speed up the static-table lookup.
Replace the unconditional 99-entry linear scan in the static-table name lookup with a binary search over an auxiliary name-sorted index (the table itself can't be reordered, so the index is built once and sorted by name). Behavior is unchanged — it returns the sole exact match or the highest-indexed name match, exactly as the linear scan did.

Behavior changes worth calling out

  • proxy.config.http3.header_table_size > 0 or proxy.config.http3.qpack_blocked_streams > 0 now fails at config load (both default to 0). These never worked correctly; failing loudly is preferable to advertising a capability we can't honor.
  • A header block referencing the dynamic table is now rejected (decode error) rather than blocked indefinitely.
  • The static table changes the wire representation back to RFC 9204 — i.e., it corrects the indices ATS emits/accepts for static references ≥ 42.

Testing

Built and tested against the BoringSSL H3 toolchain
(-DENABLE_QUICHE=ON -DOPENSSL_ROOT_DIR=.../boringssl -Dquiche_ROOT=.../quiche):

  • test_qpack — 12 assertions / 4 cases pass. The conformance test confirms, through the real decode path, that index
    42 → content-encoding: br,
    52 → content-type: text/html; charset=utf-8,
    71 → :status 500,
    98 → x-frame-options: sameorigin.
  • test_http3 — 125 assertions / 14 cases pass.

The new static-table lookup was additionally checked for bit-identical behavior
against the previous linear scan over an exhaustive set of inputs.

Out of scope / follow-ups

  • QPACK decode-error propagation is incomplete independent of this PR: the QPACK_EVENT_DECODE_FAILED handler and the res < 0 path in Http3HeaderVIOAdaptor are currently // FIXME no-ops (failures log but don't reset the stream/connection). Worth a separate change.

🤖 Generated with Claude Code

phongn and others added 6 commits June 11, 2026 19:58
Replace the unconditional 99-entry linear scan in the static-table
name lookup with a binary search over an auxiliary name-sorted index.
The static table cannot be reordered (its indices are wire values), so
the index is built once and sorted by name. Behavior is unchanged: the
lookup returns the sole exact match or the highest-indexed name match,
exactly as the linear scan did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decoder's Required Insert Count / Delta Base prefix decoding had
three defects on malformed input:

- _decode_header read an uninitialized `tmp` and used `&&` instead of
  `||`, so a failed integer decode was usually not caught and `pos`
  was then advanced by a negative return value.
- The Delta Base check compared `< 0xFFFF` (inverted), failing to
  reject oversized values.
- `remain_len` was never updated as `pos` advanced, so per-instruction
  decoders received an end pointer past the real buffer, allowing an
  over-read on a crafted length.

Decode against a fixed `end` pointer, initialize the prefix integers,
reject on `|| value > 0xFFFF`, and recompute the remaining length each
instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The QPACK dynamic table is not implemented (entries are never stored),
so advertising a non-zero header_table_size or qpack_blocked_streams
would make peers insert entries we drop and then reference, breaking
decoding. Fail at config load if either is set non-zero instead of
silently misbehaving on the wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The QPACK static table is normative: its 99 indices (RFC 9204
Appendix A) are wire values shared with every peer and cannot be
extended or reordered. apache#12201 added a 100th entry ("content-encoding:
zstd") in the middle and appended "zstd" to entry 31's value, shifting
RFC indices 42-98 to 43-99 and altering index 31. A peer using the
standard table then mis-resolves every static reference at or above
42 (content-type variants, the second :status block, user-agent,
etc.), corrupting headers in both directions; it also makes
"content-encoding: zstd" itself decode as "br" on a standard client.

Restore the table to RFC 9204 exactly. zstd content-coding is
unaffected: it is still encoded as a literal field, which is how every
compliant QPACK implementation conveys values absent from the static
table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A header block with a non-zero Required Insert Count references the
dynamic table. Entries are only stored once a non-zero table capacity
is negotiated, and ATS advertises zero, so such a reference can never
be satisfied -- the decoder would queue the stream as blocked forever.
Fail the decode instead when the table capacity is zero. Adds a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decode header blocks that reference static-table indices spanning the
range a prior change shifted (31, 42, 52, 71, 98) and assert each
yields the exact RFC 9204 Appendix A name and value. This pins the
normative table and fails if an entry is ever inserted or reordered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant