Стенд: один nginx, одно соединение, четыре одинаковых запроса подряд. У запроса крупный authorization на 244 байта и постоянный x-request-id. Меряем размер сжатого блока заголовков в обе стороны:
ответ (nginx → клиент): 131 131 131 131 запрос (клиент → nginx): 246 8 8 8
Верхняя строка — константа. Сколько запросов ни повтори, ответ занимает одни и те же 131 байт. Нижняя — тот же набор заголовков, только в другую сторону: со второго запроса он схлопывается в тридцать раз.
Замеров у HTTP/3 четыре, у HTTP/2 три — просто разные клиенты: h3_probe.py шлёт четыре запроса, curl в run.sh три. На константу это не влияет.
Это не баг и не просчёт конфигурации. Это HTTP/3 в дефолтной конфигурации: модуль, конечно, надо собрать явно (--with-http_v3_module, в auto/options оба HTTP/2 и HTTP/3 по умолчанию NO), но дальше в конфиге только listen 8443 quic и больше ничего. В HTTP/2 к тому же серверу картина ровно та же: 129 129 129, константа.
Между тем половина смысла HPACK и QPACK — динамическая таблица: повторяющийся заголовок отправляется один раз, дальше идут ссылки на номер. Именно она даёт те самые «заголовки сжимаются в разы», ради которых бинарные протоколы и затевались. Клиент ей пользуется — вы видите результат в первой строке. Сервер не пользуется ни в одном из двух протоколов.
Ни в access-логе, ни в error-логе на штатном уровне этого не видно. В документации nginx нет ни слова. А в RFC разрешено — но найти это разрешение можно, только если знать, что искать.
При этом две реализации QPACK из пяти кодировщиком в таблицу всё-таки пишут. Не угадаете, какие — и именно приёмная половина, та самая, которой сервер сам не пользуется, этой весной принесла nginx use-after-free с оценкой 9.2.
Весь разбор — по фиксированным версиям:
проект | версия | откуда |
|---|---|---|
| сборка из тега | |
| полная история, для разбора фикса | |
| сборка из тега | |
| сборка из тега | |
| сборка из тега | |
| тегов нет, снимок ветки |
Последняя строка требует оговорки: google/quiche — непрерывное зеркало внутреннего репозитория Google, релизных тегов у него не существует. Если склонируете main через месяц, увидите другой код. Все остальные строки воспроизводимы точно.
Что nginx отдаёт
Кодировщик заголовков ответа в HTTP/3 живёт в ngx_http_v3_filter_module.c. Каждое поле кодируется одной из трёх функций: ссылка на запись таблицы, литерал с именем из таблицы, полный литерал. У первых двух есть параметр dynamic — он и выбирает, из какой таблицы берётся индекс:
uintptr_t ngx_http_v3_encode_field_ri(u_char *p, ngx_uint_t dynamic, ngx_uint_t index) { /* Indexed Field Line */ if (p == NULL) { return ngx_http_v3_encode_prefix_int(NULL, index, 6); } *p = dynamic ? 0x80 : 0xc0; return ngx_http_v3_encode_prefix_int(p, index, 6); }
src/http/v3/ngx_http_v3_encode.c:121–133
Бит 0x40 здесь — это флаг T из RFC 9204 (у литерала с ссылкой на имя тот же флаг живёт в 0x10, ngx_http_v3_encode.c:151): единица означает статическую таблицу, ноль — динамическую. dynamic ? 0x80 : 0xc0 ровно это и кодирует.
Теперь посмотрим, с каким значением dynamic эти функции вызываются. Не буду фильтровать грепом — покажу все вызовы целиком, чтобы второй аргумент было видно глазами:
git grep -n 'ngx_http_v3_encode_field_ri(\|ngx_http_v3_encode_field_lri(' \ release-1.31.3 -- src/http/v3/ngx_http_v3_filter_module.c
…filter_module.c:153: len += ngx_http_v3_encode_field_ri(NULL, 0, …filter_module.c:157: len += ngx_http_v3_encode_field_lri(NULL, 0, …filter_module.c:175: len += ngx_http_v3_encode_field_lri(NULL, 0, …filter_module.c:181: len += ngx_http_v3_encode_field_lri(NULL, 0, NGX_HTTP_V3_HEADER_DATE, …filter_module.c:194: len += ngx_http_v3_encode_field_lri(NULL, 0, …filter_module.c:201: len += ngx_http_v3_encode_field_lri(NULL, 0, …filter_module.c:206: len += ngx_http_v3_encode_field_ri(NULL, 0, …filter_module.c:214: len += ngx_http_v3_encode_field_lri(NULL, 0, …filter_module.c:280: len += ngx_http_v3_encode_field_lri(NULL, 0, …filter_module.c:288: len += ngx_http_v3_encode_field_ri(NULL, 0, …filter_module.c:335: b->last = (u_char *) ngx_http_v3_encode_field_ri(b->last, 0, …filter_module.c:339: b->last = (u_char *) ngx_http_v3_encode_field_lri(b->last, 0, …filter_module.c:362: b->last = (u_char *) ngx_http_v3_encode_field_lri(b->last, 0, …filter_module.c:372: b->last = (u_char *) ngx_http_v3_encode_field_lri(b->last, 0, …filter_module.c:408: b->last = (u_char *) ngx_http_v3_encode_field_lri(b->last, 0, …filter_module.c:425: b->last = (u_char *) ngx_http_v3_encode_field_lri(b->last, 0, …filter_module.c:433: b->last = (u_char *) ngx_http_v3_encode_field_ri(b->last, 0, …filter_module.c:453: b->last = (u_char *) ngx_http_v3_encode_field_lri(b->last, 0, …filter_module.c:463: b->last = (u_char *) ngx_http_v3_encode_field_lri(b->last, 0, …filter_module.c:474: b->last = (u_char *) ngx_http_v3_encode_field_ri(b->last, 0, …filter_module.c:644: len += ngx_http_v3_encode_field_ri(NULL, 0, NGX_HTTP_V3_HEADER_STATUS_103); …filter_module.c:661: b->last = (u_char *) ngx_http_v3_encode_field_ri(b->last, 0,
(префикс release-1.31.3:src/http/v3/ сокращён, чтобы строки влезли по ширине)
Двадцать два вызова, все в одном файле, и у каждого второй аргумент — ноль. Ни одной ссылки в динамическую таблицу.
Дальше — префикс секции полей. Он идёт перед списком заголовков и несёт два числа: insert_count (сколько записей динамической таблицы нужно декодировщику, чтобы разобрать эту секцию) и delta_base. Вызовов шесть, три размерных и три пишущих:
b->last = (u_char *) ngx_http_v3_encode_field_section_prefix(b->last, 0, 0, 0);
src/http/v3/ngx_http_v3_filter_module.c:327
Сигнатура — (u_char *p, ngx_uint_t insert_count, ngx_uint_t sign, ngx_uint_t delta_base), то есть оба числа и знак — нули. И так во всех шести вызовах, а живут они в трёх разных функциях: ngx_http_v3_header_filter (строка 85), ngx_http_v3_early_hints_filter (595) и ngx_http_v3_create_trailers (856). Заголовки, ранние подсказки, трейлеры — любая секция полей, которую nginx отправляет, объявляет: из динамической таблицы я не взял ничего.
Есть и третье доказательство, самое короткое. Чтобы писать в динамическую таблицу собеседника, нужен encoder-поток — отдельный однонаправленный QUIC-поток типа 0x02, по которому идут инструкции вставки. Посмотрим, какие однонаправленные потоки nginx открывает сам:
git grep -n 'get_uni_stream(' release-1.31.3 -- src/ | grep -v '\.h:'
release-1.31.3:src/http/v3/ngx_http_v3_request.c:142: if (ngx_http_v3_get_uni_stream(c, NGX_HTTP_V3_STREAM_DECODER) == NULL) { release-1.31.3:src/http/v3/ngx_http_v3_uni.c:312:ngx_http_v3_get_uni_stream(ngx_connection_t *c, ngx_uint_t type) release-1.31.3:src/http/v3/ngx_http_v3_uni.c:407: cc = ngx_http_v3_get_uni_stream(c, NGX_HTTP_V3_STREAM_CONTROL); release-1.31.3:src/http/v3/ngx_http_v3_uni.c:462: cc = ngx_http_v3_get_uni_stream(c, NGX_HTTP_V3_STREAM_CONTROL); release-1.31.3:src/http/v3/ngx_http_v3_uni.c:505: dc = ngx_http_v3_get_uni_stream(c, NGX_HTTP_V3_STREAM_DECODER); release-1.31.3:src/http/v3/ngx_http_v3_uni.c:546: dc = ngx_http_v3_get_uni_stream(c, NGX_HTTP_V3_STREAM_DECODER); release-1.31.3:src/http/v3/ngx_http_v3_uni.c:586: dc = ngx_http_v3_get_uni_stream(c, NGX_HTTP_V3_STREAM_DECODER);
Семь совпадений, из них строка 312 — определение самой функции, вызовов шесть. Только CONTROL и DECODER. Ветка создания серверного encoder-потока в коде есть — index = NGX_HTTP_V3_STREAM_SERVER_ENCODER в ngx_http_v3_uni.c:323 — и в неё никто никогда не заходит.
Первая строка вывода стоит под условием, и его стоит прочитать:
if (h3scf->max_table_capacity > 0) { if (ngx_http_v3_get_uni_stream(c, NGX_HTTP_V3_STREAM_DECODER) == NULL) {
src/http/v3/ngx_http_v3_request.c:141–142
Свой decoder-поток nginx открывает только потому, что ёмкость ненулевая. Насколько это «настройка» — вопрос, к которому вернёмся в конце.
Это разрешено прямым текстом, RFC 9204 §4.2:
An endpoint MAY avoid creating an encoder stream if it will not be used (for example, if its encoder does not wish to use the dynamic table or if the maximum size of the dynamic table permitted by the peer is zero).
И последняя деталь, самая красноречивая. В QPACK есть отдельная пара представлений — Post-Base Index. Она нужна для ссылок на записи, добавленные в таблицу уже после того, как секция начала кодироваться, и адресует исключительно динамическую таблицу; статического варианта у неё нет по построению. RFC 9204 §4.5.3:
An indexed field line with post-Base index representation identifies an entry in the dynamic table with an absolute index greater than or equal to the value of the Base.
nginx её реализовал. Обе функции, pbi и lpbi, написаны и объявлены в заголовке:
git grep -n 'encode_field_pbi\|encode_field_lpbi' release-1.31.3
…/ngx_http_v3_encode.c:247:ngx_http_v3_encode_field_pbi(u_char *p, ngx_uint_t index) …/ngx_http_v3_encode.c:262:ngx_http_v3_encode_field_lpbi(u_char *p, ngx_uint_t index, u_char *data, …/ngx_http_v3_encode.h:29:uintptr_t ngx_http_v3_encode_field_pbi(u_char *p, ngx_uint_t index); …/ngx_http_v3_encode.h:30:uintptr_t ngx_http_v3_encode_field_lpbi(u_char *p, ngx_uint_t index,
(префикс release-1.31.3:src/http/v3 сокращён)
Четыре вхождения: два определения, два объявления. Вызовов нет. Для сравнения, у парных функций, которые в работе, — те же два и два плюс двадцать два вызова в фильтре.
Это сильнее двадцати двух нулей. Ноль в аргументе можно списать на осторожность — а тут написан код, который умеет ровно то, чего кодировщик не делает никогда и, судя по всему, не собирался.
Статья не про нарушение стандарта. Она про то, что этим разрешением воспользовались практически все.
Что nginx принимает
Теперь встречное направление. Первое, что nginx сообщает клиенту при установке соединения, — насколько большую динамическую таблицу тот может себе позволить:
p = (u_char *) ngx_http_v3_encode_varlen_int(p, NGX_HTTP_V3_PARAM_MAX_TABLE_CAPACITY); p = (u_char *) ngx_http_v3_encode_varlen_int(p, h3scf->max_table_capacity); p = (u_char *) ngx_http_v3_encode_varlen_int(p, NGX_HTTP_V3_PARAM_BLOCKED_STREAMS); p = (u_char *) ngx_http_v3_encode_varlen_int(p, h3scf->max_blocked_streams);
src/http/v3/ngx_http_v3_uni.c:423–428
Значение по умолчанию:
#define NGX_HTTP_V3_MAX_TABLE_CAPACITY 4096
src/http/v3/ngx_http_v3.h:46
Четыре килобайта. Сервер, который сам не запишет в таблицу ни байта, разрешает клиенту держать её на четыре килобайта — и обязан всё это принять и обслужить.
Обслуживает он всерьёз. В ngx_http_v3_table.c лежит полноценная реализация: вставка записей, вытеснение по достижении ёмкости, учёт абсолютных индексов, отслеживание заблокированных потоков, проверка лимитов с отдельными ошибками на превышение. Вытеснение выглядит так:
while (dt->size > target) { field = dt->elts[n++]; size = ngx_http_v3_table_entry_size(&field->name, &field->value); ngx_log_debug4(NGX_LOG_DEBUG_HTTP, c->log, 0, "http3 evict [%ui] \"%V\":\"%V\" size:%uz", dt->base, &field->name, &field->value, size); ngx_free(field); dt->size -= size; }
src/http/v3/ngx_http_v3_table.c:389–399
Вот та же асимметрия одним взглядом:

Проверим на живом соединении, что приёмная половина реально работает. Отладочный лог nginx на том же прогоне, что дал цифры из начала статьи:
http3 set capacity 4096 http3 insert [0] ":authority":"localhost:8443", size:56 http3 insert [1] ":path":"/probe.txt", size:47 http3 insert [2] "authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.AAAA…", size:289 http3 insert [3] "x-request-id":"fixed-value-for-repeat-test", size:71
Четыре вставки, дальше — двенадцать обращений вида http3 dynamic[N] lookup на четыре запроса. Клиент положил в таблицу всё, что повторяется, и дальше слал номера. Отсюда и восемь байт.
Клиентом здесь работает aioquic, а кодировщик у него — pylsqpack, то есть биндинги к ls-qpack. Запомните это, к нему вернёмся.
В HTTP/2 то же самое, другим механизмом
Проверять HTTP/2 я пошёл только чтобы получить базу для сравнения: логично же, что HPACK со своей динамической таблицей ответы жмёт, а QPACK разучился. Оказалось наоборот.
Одна команда закрывает вопрос. Считаем строки со словом hpack в двух файлах — в том, который формирует ответ, и в том, который разбирает запрос:
git show release-1.31.3:src/http/v2/ngx_http_v2_filter_module.c | grep -c hpack # отдача git show release-1.31.3:src/http/v2/ngx_http_v2_table.c | grep -c hpack # приём
0 62
Ноль против шестидесяти двух. Ни ngx_http_v2_hpack_t, ни одного обращения к ней в модуле, который собирает ответ, нет: своей таблицы у кодировщика не существует. Вся структура существует ради разбора клиентских запросов.
Это, правда, про структуру, а не про протокол — к тому, что модуль отдачи всё-таки пишет в HPACK, вернёмся через абзац.
Вместо динамической таблицы кодировщик ответа делает две вещи. Первая — явно её выключает. Когда клиент присылает SETTINGS_HEADER_TABLE_SIZE, взводится флаг:
case NGX_HTTP_V2_HEADER_TABLE_SIZE_SETTING: h2c->table_update = 1; break;
src/http/v2/ngx_http_v2.c:2261–2264
А на ближайшем HEADERS-фрейме флаг превращается в один байт:
if (h2c->table_update) { ngx_log_debug0(NGX_LOG_DEBUG_HTTP, fc->log, 0, "http2 table size update: 0"); *pos++ = (1 << 5) | 0; h2c->table_update = 0; }
src/http/v2/ngx_http_v2_filter_module.c:425–430
(1 << 5) | 0 — это Dynamic Table Size Update из RFC 7541 с новым размером ноль. Клиент сказал «я готов держать твою таблицу», nginx ответил «моя таблица нулевая». Тот же блок продублирован в фильтре ранних подсказок (:740–745, внутри ngx_http_v2_early_hints_filter), так что инструкция уйдёт и с 103 Early Hints. В кадре трейлеров её нет — но туда она и не нужна: флаг сбрасывается первым же блоком заголовков, а размер таблицы объявляется один раз на соединение.
И вот тут становится интересно, потому что это не единственная инструкция про таблицу, которую модуль отдачи выписывает. Есть вторая, с противоположным знаком:
git grep -n 'ngx_http_v2_inc_indexed(' release-1.31.3 \ -- src/http/v2/ngx_http_v2_filter_module.c
…filter_module.c:440: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_STATUS_INDEX); …filter_module.c:462: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_SERVER_INDEX); …filter_module.c:493: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_DATE_INDEX); …filter_module.c:499: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_CONTENT_TYPE_INDEX); …filter_module.c:541: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_CONTENT_LENGTH_INDEX); …filter_module.c:551: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_LAST_MODIFIED_INDEX); …filter_module.c:572: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_LOCATION_INDEX); …filter_module.c:582: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_VARY_INDEX); …filter_module.c:751: *pos++ = ngx_http_v2_inc_indexed(NGX_HTTP_V2_STATUS_INDEX);
Девять вызовов, и это :status, server, date, content-type, content-length, last-modified, location, vary — почти весь стандартный ответ. А макрос — вот:
#define ngx_http_v2_inc_indexed(i) (64 + (i))
src/http/v2/ngx_http_v2.h:372
64 + i — префикс 01, Literal Header Field with Incremental Indexing. RFC 7541 §6.2.1 не оставляет разночтений:
A literal header field with incremental indexing representation results in appending a header field to the decoded header list and inserting it as a new entry into the dynamic table.
То есть nginx не просто не пользуется динамической таблицей. Он велит клиенту заполнять её — записями, на которые сам не сошлётся ни разу. Девять — это места в коде, а не инструкции в одном ответе: восемь ветвей условные и срабатывают не все сразу (location и vary — по обстоятельствам, content-length и last-modified тоже), девятая живёт в фильтре ранних подсказок. В обычном ответе таких инструкций пять-шесть.
И вот теперь понятно, зачем нужен тот однобайтовый Size Update = 0. Он не предосторожность, он противоядие:
Клиент прислал
SETTINGS_HEADER_TABLE_SIZE— а браузеры его шлют. nginx первым байтом блока выставляет размер таблицы в ноль, и все девять мест становятся no-op: вставлять некуда.Клиент не прислал — как curl с nghttp2, то есть как мой собственный стенд. Тогда размер остаётся дефолтным, и записи реально ложатся в декодировочную таблицу клиента. Лежат до вытеснения. Ссылок на них не будет никогда.
Вторая вещь — все ссылки в ответе идут на статику. Семь вызовов ngx_http_v2_indexed(), и все семь — на индексы 8–14:
#define NGX_HTTP_V2_STATUS_INDEX 8 #define NGX_HTTP_V2_STATUS_200_INDEX 8 #define NGX_HTTP_V2_STATUS_204_INDEX 9 #define NGX_HTTP_V2_STATUS_206_INDEX 10 #define NGX_HTTP_V2_STATUS_304_INDEX 11 #define NGX_HTTP_V2_STATUS_400_INDEX 12 #define NGX_HTTP_V2_STATUS_404_INDEX 13 #define NGX_HTTP_V2_STATUS_500_INDEX 14
src/http/v2/ngx_http_v2.h:394–401
Это записи статической таблицы HPACK под :status. Ни одной ссылки на динамическую запись во всём модуле нет — что не помешает ему девять раз попросить туда что-нибудь положить, но об этом ниже.
Итого: два протокола, разные механизмы, одинаковый результат.
HTTP/2 | HTTP/3 | |
|---|---|---|
приём от клиента | полная таблица HPACK | полная таблица QPACK: вставка, вытеснение, заблокированные потоки |
отдача клиенту | размер таблицы выставляется в ноль | таблица не используется, encoder-поток не открывается |
ссылки в ответе | только статические индексы | только статические индексы и литералы |
инструкции таблицы в ответе | size update = 0 (если клиент прислал SETTINGS), плюс вставки; ссылок нет | нет вообще |
Одна оговорка по замерам. В моём прогоне строка http2 table size update: 0 не появилась ни разу: инструкция отправляется только если клиент прислал SETTINGS_HEADER_TABLE_SIZE, а curl с nghttp2 его не шлёт. Механизм в коде реальный и однозначный, но на стенде он не сработал — так что это утверждение подтверждено кодом, а не замером. Браузеры этот SETTINGS шлют, но отдельно я не проверял.
И ещё цифра, которую стоит прочитать правильно: ответ по HTTP/3 занял 131 байт против 129 по HTTP/2. Разница в два байта — ровно столько занимает префикс секции полей QPACK, которого в HPACK нет. Совпадение точное, и всё же выдавать его за причину я не буду. Во-первых, числа сняты в разных точках: HTTP/2 — из отладочного лога сервера, HTTP/3 — на клиенте, разбором длины HEADERS-фрейма. Во-вторых, статические таблицы у HPACK и QPACK разные (61 запись против 99), индексы разные, и побайтово я не проверял, что остальные различия взаимно сократились. Считайте это наблюдением.
Кто ещё так делает
Пять реализаций QPACK, вопрос один: пишет ли кодировщик в динамическую таблицу.
quic-go — нет, и это написано в README пакета прямым текстом:
However, it does not support the dynamic table and relies solely on the static table and string literals (including Huffman encoding), which limits compression efficiency.
В пакете просто нет файла динамической таблицы: есть static_table.go, парного нет. Весь кодировщик — 95 строк, и первое, что делает WriteField(), — записывает два нуля:
func (e *Encoder) WriteField(f HeaderField) error { // write the Header Block Prefix if !e.wrotePrefix { e.buf = appendVarInt(e.buf, 8, 0) e.buf = appendVarInt(e.buf, 7, 0) e.wrotePrefix = true }
encoder.go:26–32, тег v0.6.0
Заимствование при этом объявлено: тот же README первой фразой говорит, что кодировщик и декодировщик Хаффмана взяты из HPACK-реализации стандартной библиотеки Go, и в шапке импортов стоит "golang.org/x/net/http2/hpack". Реализация, полностью отказавшаяся от динамической таблицы QPACK, кодовую таблицу у HPACK всё же берёт.
Cloudflare quiche — нет. Каталог h3/qpack/ состоит из четырёх файлов: decoder.rs, encoder.rs, mod.rs, static_table.rs. Динамической таблицы среди них нет, а encoder.rs целиком укладывается в 235 строк, и первое, что делает encode(), — вот это:
// Required Insert Count. encode_int(0, 0, 8, &mut b)?; // Base. encode_int(0, 0, 7, &mut b)?;
quiche/src/h3/qpack/encoder.rs:51–55, тег 0.29.3
Три разных языка, один и тот же приём: два захардкоженных нуля в префиксе секции. У nginx это ngx_http_v3_encode_field_section_prefix(b->last, 0, 0, 0), у quiche — encode_int(0, 0, ...), у quic-go — appendVarInt(e.buf, 8, 0).
А теперь те, кто таблицу всё-таки ведёт.
ls-qpack от LiteSpeed — да. В нём есть настоящая вставка записи, lsqpack_enc_push_entry (lsqpack.c:1040), и вызывается она из пути кодирования — строки 1604 и 2127. Насколько это штатный режим, видно по устройству инициализации. lsqpack_enc_preinit — не альтернатива обычному пути, а его первая стадия: она существует для окна, пока не пришли SETTINGS собеседника и пользоваться таблицей нельзя. Комментарий тут же велит вызвать lsqpack_enc_init, как только они придут, а флаг LSQPACK_ENC_OPT_STAGE_2 (:76) прямо говорит «энкодер был пре-инициализирован, часть шагов можно пропустить». Работа без таблицы здесь — не режим, а состояние до знакомства.
/** * Initialize the encoder so that it can be used without using the * dynamic table. Once peer's settings are known, call * @ref lsqpack_enc_init(). * * `logger_ctx' can be set to NULL if no special logging is set up. */ void lsqpack_enc_preinit (struct lsqpack_enc *, void *logger_ctx);
lsqpack.h:108–116, тег v2.7.0
Показательно и то, какие рядом лежат флаги. LSQPACK_ENC_OPT_NO_DUP и LSQPACK_ENC_OPT_IX_AGGR существуют не чтобы индексацию включать, а чтобы её портить — и оба стоят под одним комментарием, который помечает их отладочными:
/* The options below are advanced. The author only uses them for debugging * or testing. */
lsqpack.h:78–80
У первого приписка «отключение dup-инструкций обычно заметно ухудшает сжатие», у второго — «игнорирование истории обычно заметно ухудшает сжатие». То есть в ls-qpack динамическая таблица не опция, которую включают, а нормальный режим, который ломают только в отладку.
Google QUICHE — да, и с размахом. В qpack_encoder.cc вставка в таблицу вызывается в четырёх местах, плюс отправка всех трёх видов инструкций:
uint64_t new_index = header_table_.InsertEntry(name, value);
quiche/quic/core/qpack/qpack_encoder.cc:168 (а также :211, :244, :284)
Рядом — SendInsertWithNameReference, SendInsertWithoutNameReference, SendDuplicate и SendSetDynamicTableCapacity. Это код Chromium, он же используется в Envoy.
Расклад:
реализация | язык | пишет в динамическую таблицу |
|---|---|---|
nginx | C | нет |
quic-go | Go | нет |
Cloudflare quiche | Rust | нет |
ls-qpack (LiteSpeed) | C | да |
Google QUICHE (Chromium, Envoy) | C++ | да |

Напрашивается вывод «серверы отказались, браузеры реализовали» — и он не выдерживает собственной таблицы. Google QUICHE стоит и в Envoy, а это серверная сторона массового веба ровно в том же смысле, что nginx или Cloudflare.
Граница проходит не там. Похоже, дело в том, для кого писался кодировщик: Google QUICHE стоит в Chromium и Envoy, ls-qpack — в LiteSpeed и в aioquic, то есть обе полные реализации обслуживают в том числе клиентскую сторону. А клиенту динамическая таблица окупается сразу и очевидно — у него как раз повторяющиеся заголовки, что и видно в первой строке замера.
Но и это не закон, и контрпример стоит в моей же таблице версий: quic-go/qpack — тоже отдельная библиотека в отдельном репозитории, её тянет всё, что построено на quic-go, включая Caddy, и клиентов там не меньше. Таблицы в ней всё равно нет. Так что честная формулировка такая: полная реализация встречается там, где кодировщик писали под обе стороны, — но три из пяти команд решили, что им хватит статической, и одна из этих трёх тоже библиотека.
Это, кстати, объясняет цифры со стенда. Клиентом там был aioquic поверх ls-qpack — то есть единственная сторона в эксперименте, у которой кодировщик умеет в динамическую таблицу. Отсюда и 246 → 8. Поставьте клиентом что-нибудь на quic-go — и схлопывания не будет вовсе, обе стороны будут слать литералы.
Цена неиспользуемой половины
Сервер не пишет в динамическую таблицу. Но принять её от клиента обязан, потому что сам же объявил ёмкость 4096. Значит, в нём живёт код, который парсит insert-инструкции, аллоцирует под них буфер, ведёт таблицу и вытесняет из неё записи. Код, который не приносит серверу никакой пользы — и который смотрит наружу.
17 июня 2026 года F5 опубликовала CVE-2026-42530: use-after-free в ngx_http_v3_module, CVSS 4.0 9.2 CRITICAL, CWE-416. Та же карточка даёт 8.1 HIGH по CVSS 3.1 — обе оценки от F5 как CNA, Red Hat в роли ADP свои 8.1 подтвердил, собственной оценки NIST до сих пор нет. Поэтому в поиске попадаются обе цифры.
Что стоит за девяткой, описание NVD говорит прямо: перезапуск рабочего процесса, а на системах с отключённым ASLR — или там, где атакующий умеет его обойти, — исполнение кода. По данным NVD затронуты версии 1.31.0 ≤ v < 1.31.2. Формулировка триггера:
a remote unauthenticated attacker along with conditions beyond their control can use a specially crafted HTTP/3 session to reopen a QPACK encoder stream
Переоткрыть encoder-поток. Тот самый поток, который существует ровно ради динамической таблицы и который сам nginx никогда не открывает.
Круг замыкается на том же §4.2 RFC 9204, откуда взято разрешение не открывать свой encoder-поток. Двумя абзацами выше стандарт требует обратного от принимающей стороны:
Each endpoint MUST initiate, at most, one encoder stream and, at most, one decoder stream. Receipt of a second instance of either stream type MUST be treated as a connection error of type H3_STREAM_CREATION_ERROR.
Одна секция стандарта разрешает серверу не иметь своего encoder-потока и обязывает его отвергать второй клиентский. CVE — ровно про вторую половину этого требования.
Починили это тремя коммитами одного дня, 19 мая 2026, автор всех трёх — Roman Arutyunyan.
Тут стоит объяснить расхождение, которое иначе бросается в глаза: 1.31.1 вышла 22 мая, через три дня после «починили», и всё равно числится уязвимой. Дело в том, что 19 мая — авторская дата. Публично все три коммита появились 17 июня, в один батч с релизом 1.31.2 и ровно в день публикации CVE:
# нужен клон с полной историей git show -s --format='%h автор %ad публично %cd' \ --date=format:'%Y-%m-%d %H:%M' 875750a4f ceccdbd2e 9e293766e
875750a4f автор 2026-05-19 15:43 публично 2026-06-17 07:40 ceccdbd2e автор 2026-05-19 15:46 публично 2026-06-17 07:40 9e293766e автор 2026-05-19 16:09 публично 2026-06-17 07:40
Обычное эмбарго: патч написан, лежит месяц, выходит вместе с анонсом. Аллокацию он трогал дважды с разницей в три минуты.
Сначала, в 15:43, — размер буфера (875750a4f): брали текущую ёмкость таблицы там, где нужна объявленная максимальная, и если клиент увеличивал ёмкость на лету, буфера не хватало.
Через три минуты, в 15:46, — сам UAF:
- dt->insert_buffer = ngx_create_temp_buf(c->pool, + dt->insert_buffer = ngx_create_temp_buf(c->quic->parent->pool, h3scf->max_table_capacity);
ceccdbd2e, src/http/v3/ngx_http_v3_table.c
Сообщение коммита объясняет всё лучше любого пересказа:
Previously, it was allocated from the encoder stream pool. This could lead to use-after-free if the stream was closed and another encoder stream was opened.
Reported by Trung Nguyen (@everping) of CyStack.
Разберём по шагам. insert_buffer — буфер под insert-инструкции клиента. Лежит он в структуре динамической таблицы, а она — поле сессии: h3c->table. То есть переживает любой отдельный поток. Память под него при этом бралась из c->pool, где c — соединение encoder-потока.
Дальше арифметика времён жизни:

Клиент открывает encoder-поток, шлёт первую insert-инструкцию. Аллоцируется буфер — из пула потока.
Поток закрывается, его пул уничтожается. Буфер освобождён, но указатель на него остался в структуре сессии, а сессия жива.
Клиент открывает второй encoder-поток. Это должно быть запрещено — но проходит, об этом ниже.
Приходит insert-инструкция.
ngx_http_v3_get_insert_bufferвидитdt->insert_buffer != NULL, считает буфер готовым и возвращает его. Запись идёт в освобождённую память.
Третий шаг — отдельная история, и её лечит третий коммит серии, ещё через двадцать минут, в 16:09. Первые два — про аллокацию, этот — про охрану: две независимые дыры, закрытые в один день. Вот как выглядела охрана от переоткрытия в уязвимой 1.31.1:
if (index >= 0) { if (h3c->known_streams[index]) { ngx_log_error(NGX_LOG_INFO, c->log, 0, "stream exists"); return NGX_HTTP_V3_ERR_STREAM_CREATION_ERROR; } h3c->known_streams[index] = c;
src/http/v3/ngx_http_v3_uni.c:153–159 на теге release-1.31.1
Проверка смотрит на known_streams[index] — живой указатель на поток. А он обнуляется при закрытии потока:
if (us && us->index >= 0) { h3c = ngx_http_v3_get_session(c); h3c->known_streams[us->index] = NULL; }
src/http/v3/ngx_http_v3_uni.c:89–92 — этот блок одинаков в обоих тегах
Закрыли поток — слот снова NULL — охрана пропускает второй encoder-поток. Фикс добавляет отдельное поле:
- if (h3c->known_streams[index]) { - ngx_log_error(NGX_LOG_INFO, c->log, 0, "stream exists"); + if (h3c->created_streams & (1 << index)) { + ngx_log_error(NGX_LOG_INFO, c->log, 0, "stream already created"); return NGX_HTTP_V3_ERR_STREAM_CREATION_ERROR; } h3c->known_streams[index] = c; + h3c->created_streams |= 1 << index;
9e293766e, src/http/v3/ngx_http_v3_uni.c
Разница двух полей — вся суть:

known_streams[i] отвечает на вопрос «поток открыт сейчас» и обнуляется при закрытии. created_streams — залипающая битовая маска, ставится один раз и не сбрасывается никогда; она отвечает на вопрос «поток этого типа открывали когда-либо».
Уязвимый код задавал первый вопрос там, где нужен второй.
Сообщение фиксирующего коммита стоит прочитать целиком, потому что оно объясняет, почему одной перевязки пула было мало:
since stream creation and connection closure are asynchronous, there could be a window where two control/encoder/decoder streams could coexist within a single cycle iteration. This could result in reusing parsing context, such as encoder insert buffer.
Обратите внимание на третью строку диффа выше — h3scf->max_table_capacity);. Это контекст, а не изменение: он появился тремя минутами раньше, в 875750a4f. К CVE F5 тот коммит напрямую не привязывает, но в дереве он предок фикса, и без него дифф читался бы иначе.
И последний штрих, который эту историю замыкает. Откуда вообще взялся insert_buffer:
# нужен клон с полной историей: git clone --branch release-1.31.2 <repo> git log --format='%h %ad %s' --date=short \ -S 'ngx_http_v3_get_insert_buffer' -- src/http/v3/ngx_http_v3_table.c | tail -1
d7dd7e9ae 2026-04-10 HTTP/3: optimize encoder stream memory usage
Previously, the encoder stream allocated each new inserted field in the connection pool. This memory was not freed until the end of the connection. Now a special insert buffer is used for all inserts.
Коммит входит в release-1.31.0 — первую версию, которую NVD помечает уязвимой, и ни в одну более раннюю. То есть буфер, ставший use-after-free, завели оптимизацией расхода памяти на приёме динамической таблицы — той самой половины, которой сервер не пользуется. От коммита до релиза 1.31.0, где он стал доступен снаружи, — пять недель; до того, как фикс был написан, — шесть (а публично он вышел ещё через месяц, см. выше).
Сложите: критическая уязвимость в буфере, который обслуживает функциональность, не используемую сервером ни в одном из двух протоколов, и появившийся ради того, чтобы эта функциональность работала эффективнее. Атакующая поверхность есть, пользы нет, стоимость сопровождения — тоже есть.
Что из этого следует
Ваши ответы не сжимаются динамически, и переключателя нет. Если вы закладывались на то, что длинный повторяющийся set-cookie, content-security-policy или заголовок трассировки уедет одним байтом со второго запроса — не уедет. Он уедет литералом, каждый раз, по обоим протоколам. Хаффман и статическая таблица работают, динамическая — нет. Директив, включающих её на отдаче, в nginx не существует: это не настройка, а свойство кодировщика.
Симметричной директивы на приём тоже нет, и это интереснее. http3_max_table_capacity и http3_max_blocked_streams в nginx когда-то были — их вынесли в декабре 2021-го коммитом 0791b5088 «QUIC: simplified configuration». От первой осталось только имя внутри текста ошибки:
"client exceeded http3_max_table_capacity limit");
src/http/v3/ngx_http_v3_table.c:324 — единственное вхождение этой строки во всём дереве. С её напарницей http3_max_blocked_streams ровно та же история, :638.
Ёмкость 4096 сегодня — константа времени компиляции: h3scf->max_table_capacity присваивается один раз из NGX_HTTP_V3_MAX_TABLE_CAPACITY (ngx_http_v3_module.c:201) и больше нигде не меняется. Директив в ngx_http_v3_module.c восемь, и ни одна не про таблицу (документация добавляет девятую, quic_bpf, — но живёт она в ngx_event_quic_bpf.c и к заголовкам отношения не имеет). Приёмную половину нельзя выключить конфигом, не выключив вместе с ней HTTP/3 целиком, — ровно это и предлагает Red Hat в качестве обходного пути к CVE.
Сколько это стоит
Это можно не оценивать, а померить — стенд тот же, меняется только конфиг.
В базовом прогоне 131 байт: восемь стандартных заголовков плюс Alt-Svc, без которого HTTP/3 не анонсируется. Добавим то, что стоит на любом проде: Content-Security-Policy (значение 268 байт), Set-Cookie с сессионным JWT (125) и Strict-Transport-Security (44). Значения взяты типовые, конкретные строки роли не играют — важно, что они длинные и от ответа к ответу не меняются.
базовый набор: 131 Б + CSP, Set-Cookie, HSTS: 516 Б
385 байт литералом — три строки заголовков занимают 501 байт сырыми, Хаффман ужимает их до 385. В каждом ответе, потому что каждый раз это литерал целиком — Хаффман их жмёт, динамическая таблица к ним не применяется.
С динамической таблицей второй и все последующие ответы на том же соединении ссылались бы на индексы: три ссылки — это 3–6 байт вместо трёхсот восьмидесяти пяти. То есть каждый следующий ответ весил бы примерно 131 базовых плюс эти несколько — округлим до 137. На соединении, по которому прошло сто запросов (обычное дело для SPA или мобильного клиента с keep-alive):
без динамической таблицы: 100 × 516 Б = 51 600 Б ≈ 52 КБ с динамической таблицей: 516 + 99 × 137 Б = 14 079 Б ≈ 14 КБ
Порядка 38 КБ на соединение, только на заголовках ответов. Умножайте на своё число соединений.
Две оговорки. Первая: правая колонка — расчёт, а не замер. Сам первый ответ в ней стоил бы дороже 516, потому что вставку трёх заголовков надо передать инструкциями по encoder-потоку, а это те же примерно 385 байт, просто в другом потоке; на сотне запросов это сдвигает 14 КБ до 14,5 КБ. Вторая: реальная экономия зависит от того, сколько запросов приходится на соединение и насколько заголовки неизменны. Но левая колонка измерена, и порядок величины виден.
Экономия не бесплатна для того, кто её выбрал. Динамическая таблица на отдаче — это состояние на соединение, синхронизация с собеседником, риск заблокированных потоков и заметно более сложный код. Три реализации из пяти решили, что для сервера это не окупается. Google QUICHE и ls-qpack решили иначе, и у них на это ресурсы есть.
Обязательная приёмная половина остаётся в любом случае. Отказ от кодировщика не избавляет от декодировщика, и это не ваш выбор: ёмкость зашита константой, а RFC 9204 §4.2 добивает вопрос отдельным требованием — «An endpoint MUST allow its peer to create an encoder stream and a decoder stream even if the connection's settings prevent their use». Даже гипотетический ноль не убрал бы encoder-поток, а CVE триггерится именно его переоткрытием. CVE-2026-42530 — напоминание, во сколько это иногда обходится.
И проверьте, что вас это не касается, шире, чем «у меня не тот nginx». NIST при разборе 2 июля добавил в карточку CPE-конфигурацию, и в ней не только сам сервер:
продукт | затронутые версии |
|---|---|
NGINX Open Source | 1.31.0 ≤ v < 1.31.2 |
NGINX Ingress Controller | 3.5.0–3.7.2, 4.0.0, 4.0.1, 5.0.0 ≤ v < 5.5.1 |
NGINX Gateway Fabric | 1.3.0–1.6.2, 2.0.0 ≤ v < 2.6.4 |
NGINX Instance Manager | 2.17.0–2.22.0 |
Если у вас Ingress Controller в кластере, вы собранный из исходников nginx в глаза не видели, а под уязвимость попадаете.
А вот пакет из дистрибутива — почти наверняка нет: Red Hat в той же карточке помечает nginx в RHEL 8, 9 и 10 как unaffected, потому что дистрибутивные ветки до 1.31.0 просто не доехали. Один продукт Red Hat при этом затронут — Hardened Images (nginx-main), на него выпущен erratum RHSA-2026:20351.
Там же лежит и единственный опубликованный обходной путь, сформулированный буквально: убрать quic из всех директив listen. То есть выключить HTTP/3 целиком — приёмную половину иначе не отключить.
Карточка живая: NIST добавил CPE-конфигурацию 2 июля, Red Hat правил её 29 июня, 14 и 16 июля, и сейчас она помечена «Modified After Enrichment». Данные выше — по состоянию на 16 июля.
Чего я не знаю
Три вещи, которые я не проверил и врать про них не буду.
Первое: что объявляет в своих SETTINGS Cloudflare quiche. Я подтвердил только отсутствие динамической таблицы в кодировщике — это другое утверждение, и путать их нельзя.
Второе: как ведут себя браузеры. Инструкция «размер таблицы = 0» в HTTP/2 у меня на стенде не сработала, потому что curl не шлёт SETTINGS_HEADER_TABLE_SIZE. Chromium использует Google QUICHE, у которого кодировщик полноценный, — но что именно он делает на реальном сайте, я не мерил.
Третье: сколько это стоит в вашем проде. Считать по моему стенду нельзя: он однопроцессный, локальный, без потерь и без RTT.
Отсюда две просьбы.
Дешёвая, на одну строку. Загляните в свои ответы: есть ли там заголовок длиннее сотни байт, который уходит на каждый запрос неизменным? content-security-policy, set-cookie, server-timing, трассировочные заголовки. Просто напишите в комментариях, какой и сколько байт. Мне интересно, у скольких людей эта неэффективность реально что-то стоит.
Дорогая, если руки дойдут. Клиентская часть стенда — ниже целиком, 64 строки. Серверная — это nginx, собранный из тега release-1.31.3 с --with-debug --with-http_v2_module --with-http_v3_module, один воркер, самоподписанный сертификат, listen 8443 quic.
probe.py — клиент стенда целиком; весь стенд поднимается в 1 клик через run.sh
import asyncio, ssl from aioquic.asyncio.client import connect from aioquic.asyncio.protocol import QuicConnectionProtocol from aioquic.quic.configuration import QuicConfiguration from aioquic.quic.events import StreamDataReceived from aioquic.h3.connection import H3Connection BIG = b"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + b"A" * 200 RESP, REQ = [], [] def varint(buf, off): b0 = buf[off]; n = 1 << (b0 >> 6); v = b0 & 0x3F for i in range(1, n): v = (v << 8) | buf[off + i] return v, off + n class Probe(QuicConnectionProtocol): h3 = None def quic_event_received(self, event): if isinstance(event, StreamDataReceived) and event.stream_id % 4 == 0 and event.data: try: t, o = varint(event.data, 0); l, o = varint(event.data, o) if t == 0x01: RESP.append(l) except Exception: pass if self.h3 is not None: self.h3.handle_event(event) super().quic_event_received(event) async def main(): cfg = QuicConfiguration(is_client=True, alpn_protocols=["h3"], verify_mode=ssl.CERT_NONE) async with connect("127.0.0.1", 8443, configuration=cfg, create_protocol=Probe) as client: h3 = H3Connection(client._quic) client.h3 = h3 orig = client._quic.send_stream_data def spy(stream_id, data, end_stream=False): if stream_id % 4 == 0 and data: try: t, o = varint(data, 0); l, o = varint(data, o) if t == 0x01: REQ.append(l) except Exception: pass return orig(stream_id, data, end_stream) client._quic.send_stream_data = spy client.transmit() await asyncio.sleep(0.8) # дать SETTINGS дойти и примениться for i in range(4): sid = client._quic.get_next_available_stream_id() h3.send_headers(sid, [ (b":method", b"GET"), (b":scheme", b"https"), (b":authority", b"localhost:8443"), (b":path", b"/probe.txt"), (b"authorization", BIG), (b"x-request-id", b"fixed-value-for-repeat-test"), ], end_stream=True) client.transmit() await asyncio.sleep(0.6) await asyncio.sleep(0.6) asyncio.run(main()) print(f"запрос (клиент → nginx, кодирует ls-qpack): {REQ}") print(f"ответ (nginx → клиент, кодирует nginx): {RESP}")
Замените клиента: возьмите вместо aioquic что-нибудь на quic-go или снимите реальный браузер через SSLKEYLOGFILE и Wireshark. Мне не хватает именно этой строки — что происходит, когда кодировщик умеет в динамическую таблицу с обеих сторон, и умеет ли Chromium на самом деле.
И контрпример, за которым я слежу сам. Утверждение «сервер не пишет в динамическую таблицу» я проверял на трёх реализациях из пяти. Если у вас есть в руках сервер, который это делает, — Apache Traffic Server, H2O, что-то на Envoy, — покажите вывод. Это ровно та строка, которая опровергнет расклад три к двум.

