Reject invalid QPACK static indexes - #13621
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new static-index validation can still be bypassed via implicit narrowing/truncation of decoded indexes (uint64_t → uint16_t), so invalid peer-supplied indexes may not be reliably rejected.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR strengthens HTTP/3 QPACK robustness by rejecting invalid peer-supplied static table indexes and by treating failed encoder-stream name-reference lookups as fatal before attempting dynamic table insertion.
Changes:
- Add bounds-checking to QPACK static table lookups to prevent out-of-range access.
- Abort decoding when an encoder-stream “Insert With Name Reference” lookup does not resolve to an EXACT match (static or dynamic).
- Add a unit test asserting that decoding fails for an out-of-range static table index.
File summaries
| File | Description |
|---|---|
| src/proxy/http3/QPACK.cc | Adds static-table bounds checking and enforces lookup success for encoder-stream insert-with-name-ref before inserting into the dynamic table. |
| src/proxy/http3/test/test_QPACK.cc | Adds a decode-failure test case for an out-of-range static-table index. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
The clang-analyzer job's Clang-Tidy stage failure here is not from this change. The only diagnostic is pre-existing on master: That is fixed by #13622. I will rebase this branch once that merges. |
|
[approve ci clang-analyzer] |
7a84b8b to
87c9323
Compare
There was a problem hiding this comment.
🟡 Changes recommended
There is a concrete error-path handling bug in _read_insert_with_name_ref() that can fall through on decode failure (risking incorrect buffer consumption), and the new polling-based tests introduce a cross-thread data race without atomic/mutex protection.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
src/proxy/http3/QPACK.cc:1542
- The value decode error check can fall through on xpack_decode_string() failure:
tmpis uninitialized on error, and if the condition is false the code continues with a negativeret, which can wrapread_len(size_t) and consume the wrong amount of data. Treat anyret < 0from xpack_decode_string() as a hard failure before updatingread_len.
// Value
if ((ret = xpack_decode_string(arena, value, tmp, input + read_len, input + input_len, _header_field_max_size, 7)) < 0 &&
tmp > 0xFF) {
return -1;
}
src/proxy/http3/test/test_QPACK.cc:116
TestQPACKEventHandler::_eventis written from an event thread and read from the test thread (via wait_for_event polling). As a plain int this is a data race (UB) and can make the new polling helper flaky under TSAN or on weakly-ordered CPUs. Make_eventatomic (or protect access with a mutex) and use load/store in the handler and accessor.
TestQPACKEventHandler() : Continuation() { SET_HANDLER(&TestQPACKEventHandler::event_handler); }
int
event_handler(int event, Event * /* data ATS_UNUSED */)
{
this->_event = event;
return 0;
}
int
last_event()
{
return this->_event;
}
private:
int _event = 0;
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
Validate peer-supplied static table indexes before reading the QPACK static table. Also honor failed encoder-stream name-reference lookups before inserting dynamic table entries. The new encoder stream test is the first to exercise QPACK::on_stream_open, which allocated a QUICStreamVCAdapter::IOInfo that nothing owned or freed. Hold it in a per-stream map and erase it in on_stream_close, the way the other QUICApplication implementations do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87c9323 to
541fc9e
Compare
|
The rocky ASan job's LeakSanitizer failure is fixed in the force push just now. Both leaks were reached for the first time by the new encoder stream test:
Both were pre-existing: nothing called Verified with a local ASan build ( |
maskit
left a comment
There was a problem hiding this comment.
Built and ran this against quiche + ASan (test_qpack): compiles clean, all three [qpack-decode] cases pass in 1.4s. I reverted each fix separately to confirm the new tests bite — without the countof check test 1 aborts, without the encoder-stream handling test 2 fails both assertions. STATIC_HEADER_FIELDS also matches RFC 9204 Appendix A entry for entry, so countof is the right bound.
Two blockers inline, plus a note on the new test's threading.
| @@ -1514,10 +1532,9 @@ QPACK::_read_insert_with_name_ref(IOBufferReader &reader, bool &is_static, uint1 | |||
|
|
|||
| // Name Index | |||
| uint64_t tmp; | |||
There was a problem hiding this comment.
With the index decode now writing index directly, nothing writes tmp before the value guard two lines down:
if ((ret = xpack_decode_string(arena, value, tmp, ...)) < 0 && tmp > 0xFF) {xpack_decode_string leaves str_length untouched on every failure return, so that condition now reads an uninitialized uint64_t. The && also needs to be a plain ret < 0 — the same fix you applied above.
Verified on master, where tmp held the index and the fall-through was therefore deterministic: encoder-stream bytes c0 7f 0a — Insert With Name Reference, static, index 0, declared value length 137, zero value bytes — make the string decode fail, the guard not fire, then read_len += -1 yields read_len == 0 and reader.consume(0), so _on_encoder_stream_read_ready re-reads the same instruction forever: 364,583 iterations in 3 seconds pinning ET_NET 0. The unset value pointer also took Arena::str_free() to SIGSEGV in the ASan build.
if ((ret = xpack_decode_string(arena, value, tmp, input + read_len, input + input_len, _header_field_max_size, 7)) < 0) {
return -1;
}| } else if (index <= std::numeric_limits<uint32_t>::max()) { | ||
| result = this->_dynamic_table.lookup(static_cast<uint32_t>(index), &name, &name_len, &dummy, &dummy_len); |
There was a problem hiding this comment.
When T=0 the Name Index is a relative index (RFC 9204 §4.3.2), and on the encoder stream relative 0 is the most recently inserted entry (§3.2.5). XpackDynamicTable::lookup() takes an absolute index, so this resolves the wrong entry — or returns NONE and kills the connection. lookup_relative() is the matching API; QPACK already does the equivalent conversion on the field-section path via _calc_absolute_index_from_relative_index.
It can't be observed today, since HTTP3_DEFAULT_HEADER_TABLE_SIZE is 0 and the table stays empty — but that's a temporary mitigation, and this would land as a fresh bug for whoever re-enables it.
The uint32_t bound goes away with the fix: it's a C++ type limit standing in for a protocol rule, and after narrowing it can't reject aliasing anyway.
| } else if (index <= std::numeric_limits<uint32_t>::max()) { | |
| result = this->_dynamic_table.lookup(static_cast<uint32_t>(index), &name, &name_len, &dummy, &dummy_len); | |
| } else { | |
| result = this->_dynamic_table.lookup_relative(index, &name, &name_len, &dummy, &dummy_len); |
One prerequisite in XPACK: lookup_relative dereferences _entries[_entries_head] before lookup's is_empty() check, and with capacity 0 the constructor leaves _entries_head == UINT32_MAX — so as it stands that call would be a wild read on the configuration we ship. The count() guard here is load-bearing, not defensive:
const XpackLookupResult
XpackDynamicTable::lookup_relative(uint64_t relative_index, const char **name, size_t *name_len, const char **value,
size_t *value_len) const
{
if (relative_index >= this->count()) {
return {0, XpackLookupResult::MatchType::NONE};
}
return this->lookup(this->largest_index() - static_cast<uint32_t>(relative_index), name, name_len, value, value_len);
}count() returns 0 when empty, so that covers the empty case and largest_index()'s assert can't fire. No behavior change for HPACK, which already bounds the index with the same quantity at its call site (HPACK.cc:344).
| }; | ||
| int ret = 0; | ||
|
|
||
| for (int i = 0; i < 500; ++i) { |
There was a problem hiding this comment.
This calls qpack.decode() from the Catch2 main thread while ET_NET 0 runs _on_encoder_stream_read_ready on the same object — _invalid, _arena, _dynamic_table and _blocked_list are all touched from both, and QPACK's Continuation mutex is never taken. It passes today but will surface under TSAN. Having TestQPACKStreamWriter do the write and then the decode on the same thread would remove both the race and the poll loop.
Same test: writer is a stack Continuation handed to schedule_imm and never cancelled, so on the timeout path it's destroyed while the event may still be queued.
Validate peer-supplied static table indexes before reading the QPACK
static table. Also honor failed encoder-stream name-reference lookups
before inserting dynamic table entries.
The new encoder stream test is the first to exercise
QPACK::on_stream_open, which allocated a QUICStreamVCAdapter::IOInfo
that nothing owned or freed. Hold it in a per-stream map and erase it in
on_stream_close, the way the other QUICApplication implementations do.