Skip to content

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

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

QPACK: RFC 9204 static-table compliance, decode robustness, and cleanup#13260
phongn wants to merge 7 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>
@cmcfarlen cmcfarlen added this to the 11.0.0 milestone Jun 15, 2026
@ezelkow1

ezelkow1 commented Jun 15, 2026

Copy link
Copy Markdown
Member

@phongn failure in qpack test:

 48/161 Test  #51: test_qpack .............................***Failed    0.28 sec
Randomness seeded to: 2101226799
couldn't open dir: ./qifs/qifs
couldn't open dir: ./qifs/encoded/ats
[Jun 11 20:40:36.968] test_qpack DIAG: <QPACK.cc:733 (_decode_indexed_header_field)> (qpack) [00000000-00000000] Decoded Indexed Header Field: base_index=0, abs_index=31, name=accept-encoding, value=gzip, deflate, br
[Jun 11 20:40:36.968] test_qpack DIAG: <QPACK.cc:733 (_decode_indexed_header_field)> (qpack) [00000000-00000000] Decoded Indexed Header Field: base_index=0, abs_index=42, name=content-encoding, value=br
[Jun 11 20:40:36.968] test_qpack DIAG: <QPACK.cc:733 (_decode_indexed_header_field)> (qpack) [00000000-00000000] Decoded Indexed Header Field: base_index=0, abs_index=52, name=content-type, value=text/html; charset=utf-8
[Jun 11 20:40:36.968] test_qpack DIAG: <QPACK.cc:733 (_decode_indexed_header_field)> (qpack) [00000000-00000000] Decoded Indexed Header Field: base_index=0, abs_index=71, name=:status, value=500
[Jun 11 20:40:36.969] test_qpack DIAG: <QPACK.cc:733 (_decode_indexed_header_field)> (qpack) [00000000-00000000] Decoded Indexed Header Field: base_index=0, abs_index=98, name=x-frame-options, value=sameorigin
===============================================================================
All tests passed (12 assertions in 4 test cases)


=================================================================
==5427==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 312 byte(s) in 1 object(s) allocated from:
    #0 0x7f219b15e307 in operator new(unsigned long) (/lib64/libasan.so.6+0xb6307)
    #1 0x452ad1 in CATCH2_INTERNAL_TEST_8 ../src/proxy/http3/test/test_QPACK.cc:536
    #2 0x7f219ad22080 in invoke ../lib/Catch2/src/catch2/internal/catch_test_registry.cpp:60
    #3 0x7f219acee741 in Catch::TestCaseHandle::invoke() const ../lib/Catch2/src/catch2/catch_test_case_info.hpp:124
    #4 0x7f219aceb27b in Catch::RunContext::invokeActiveTestCase() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:673
    #5 0x7f219acea9f0 in Catch::RunContext::runCurrentTest() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:631
    #6 0x7f219ace5948 in Catch::RunContext::runTest(Catch::TestCaseHandle const&) ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:273
    #7 0x7f219ac2aa28 in execute ../lib/Catch2/src/catch2/catch_session.cpp:108
    #8 0x7f219ac2d72f in Catch::Session::runInternal() ../lib/Catch2/src/catch2/catch_session.cpp:328
    #9 0x7f219ac2cca4 in Catch::Session::run() ../lib/Catch2/src/catch2/catch_session.cpp:260
    #10 0x4343aa in main ../src/proxy/http3/test/main_qpack.cc:105
    #11 0x7f2198433864 in __libc_start_main (/lib64/libc.so.6+0x3a864)

Direct leak of 312 byte(s) in 1 object(s) allocated from:
    #0 0x7f219b15e307 in operator new(unsigned long) (/lib64/libasan.so.6+0xb6307)
    #1 0x4518e2 in CATCH2_INTERNAL_TEST_6 ../src/proxy/http3/test/test_QPACK.cc:502
    #2 0x7f219ad22080 in invoke ../lib/Catch2/src/catch2/internal/catch_test_registry.cpp:60
    #3 0x7f219acee741 in Catch::TestCaseHandle::invoke() const ../lib/Catch2/src/catch2/catch_test_case_info.hpp:124
    #4 0x7f219aceb27b in Catch::RunContext::invokeActiveTestCase() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:673
    #5 0x7f219acea9f0 in Catch::RunContext::runCurrentTest() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:631
    #6 0x7f219ace5948 in Catch::RunContext::runTest(Catch::TestCaseHandle const&) ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:273
    #7 0x7f219ac2aa28 in execute ../lib/Catch2/src/catch2/catch_session.cpp:108
    #8 0x7f219ac2d72f in Catch::Session::runInternal() ../lib/Catch2/src/catch2/catch_session.cpp:328
    #9 0x7f219ac2cca4 in Catch::Session::run() ../lib/Catch2/src/catch2/catch_session.cpp:260
    #10 0x4343aa in main ../src/proxy/http3/test/main_qpack.cc:105
    #11 0x7f2198433864 in __libc_start_main (/lib64/libc.so.6+0x3a864)

Direct leak of 80 byte(s) in 1 object(s) allocated from:
    #0 0x7f219b15e307 in operator new(unsigned long) (/lib64/libasan.so.6+0xb6307)
    #1 0x452b00 in CATCH2_INTERNAL_TEST_8 ../src/proxy/http3/test/test_QPACK.cc:537
    #2 0x7f219ad22080 in invoke ../lib/Catch2/src/catch2/internal/catch_test_registry.cpp:60
    #3 0x7f219acee741 in Catch::TestCaseHandle::invoke() const ../lib/Catch2/src/catch2/catch_test_case_info.hpp:124
    #4 0x7f219aceb27b in Catch::RunContext::invokeActiveTestCase() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:673
    #5 0x7f219acea9f0 in Catch::RunContext::runCurrentTest() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:631
    #6 0x7f219ace5948 in Catch::RunContext::runTest(Catch::TestCaseHandle const&) ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:273
    #7 0x7f219ac2aa28 in execute ../lib/Catch2/src/catch2/catch_session.cpp:108
    #8 0x7f219ac2d72f in Catch::Session::runInternal() ../lib/Catch2/src/catch2/catch_session.cpp:328
    #9 0x7f219ac2cca4 in Catch::Session::run() ../lib/Catch2/src/catch2/catch_session.cpp:260
    #10 0x4343aa in main ../src/proxy/http3/test/main_qpack.cc:105
    #11 0x7f2198433864 in __libc_start_main (/lib64/libc.so.6+0x3a864)

Direct leak of 80 byte(s) in 1 object(s) allocated from:
    #0 0x7f219b15e307 in operator new(unsigned long) (/lib64/libasan.so.6+0xb6307)
    #1 0x451911 in CATCH2_INTERNAL_TEST_6 ../src/proxy/http3/test/test_QPACK.cc:503
    #2 0x7f219ad22080 in invoke ../lib/Catch2/src/catch2/internal/catch_test_registry.cpp:60
    #3 0x7f219acee741 in Catch::TestCaseHandle::invoke() const ../lib/Catch2/src/catch2/catch_test_case_info.hpp:124
    #4 0x7f219aceb27b in Catch::RunContext::invokeActiveTestCase() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:673
    #5 0x7f219acea9f0 in Catch::RunContext::runCurrentTest() ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:631
    #6 0x7f219ace5948 in Catch::RunContext::runTest(Catch::TestCaseHandle const&) ../lib/Catch2/src/catch2/internal/catch_run_context.cpp:273
    #7 0x7f219ac2aa28 in execute ../lib/Catch2/src/catch2/catch_session.cpp:108
    #8 0x7f219ac2d72f in Catch::Session::runInternal() ../lib/Catch2/src/catch2/catch_session.cpp:328
    #9 0x7f219ac2cca4 in Catch::Session::run() ../lib/Catch2/src/catch2/catch_session.cpp:260
    #10 0x4343aa in main ../src/proxy/http3/test/main_qpack.cc:105
    #11 0x7f2198433864 in __libc_start_main (/lib64/libc.so.6+0x3a864)

SUMMARY: AddressSanitizer: 784 byte(s) leaked in 4 allocation(s).

        Start  52: test_proxy
 49/161 Test  #52: test_proxy .............................   Passed    0.14 sec
        Start  53: test_jsonrpc

The two QPACK tests added on this branch allocated a QPACK and a
TestQPACKEventHandler with new but never freed them; LeakSanitizer
flagged the leaks in CI. Delete both at the end of each test. The
conformance test schedules an async decode-completion event that
references the handler, so wait for it to run before tearing the
handler down; the hardening test fails before scheduling anything and
frees immediately.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants