В сентябре 2025 года в нашем внутреннем чате инженеров появилось сообщение с вопросом: «Какая часть нашего стека отвечает за отправку ErrCode=ENHANCE_YOUR_CALM HTTP/2 клиенту?» Два внутренних микросервиса испытывали критическую ошибку, препятствующую их взаимодействию, и команде требовался своевременный ответ.
В этой записи блога мы описываем предысторию известных атак на HTTP/2, которые запускают защитные механизмы Cloudflare, разрывающие соединения. Затем мы документируем распространённую ошибку при использовании стандартной библиотеки Go, которая может привести к отправке клиентами PING-флуда, и объясняем, как её избежать.
HTTP/2 — мощный, но его можно легко использовать неправильно
HTTP/2 определяет бинарный формат передачи для кодирования HTTP-семантики. Сообщения запросов и ответов кодируются как серии фреймов HEADERS и DATA, каждый из которых связан с логическим потоком (stream), отправляемым по TCP-соединению с использованием TLS. Также существуют управляющие фреймы, относящиеся к управлению потоками или соединением в целом. Например, фреймы SETTINGS сообщают о свойствах конечной точки, фреймы WINDOW_UPDATE предоставляют кредит управления потоком для передачи данных, RST_STREAM может использоваться для отмены или отклонения запроса или ответа, а GOAWAY — для сигнализации о плавном или немедленном закрытии соединения.
HTTP/2 предоставляет множество мощных функций, имеющих законные применения. Однако с большой силой приходит большая ответственность и возможность случайного или намеренного неправильного использования. Спецификация подробно описывает ряд соображений по защите от отказа в обслуживании. Реализациям рекомендуется укреплять свою защиту: «Конечная точка, которая не отслеживает использование этих функций, подвергает себя риску отказа в обслуживании. Реализации ДОЛЖНЫ отслеживать использование этих функций и устанавливать ограничения на их использование.»
Cloudflare реализует множество различных защитных механизмов HTTP/2, разработанных за годы для защиты наших систем и клиентов. Некоторые notable примеры включают меры, добавленные в 2019 году для устранения «уязвимостей Netflix» и в 2023 году для смягчения атак типа Rapid Reset и аналогичных.
Когда Cloudflare обнаруживает, что поведение HTTP/2 клиента, вероятно, является злонамеренным, мы закрываем соединение с помощью фрейма GOAWAY и включаем код ошибки ENHANCE_YOUR_CALM.
Одной из известных и распространённых атак является CVE-2019-9512, также известная как PING-флуд: «Злоумышленник отправляет постоянные ping-запросы одноранговому узлу HTTP/2, заставляя узел строить внутреннюю очередь ответов. В зависимости от эффективности организации этой очереди данных, это может потреблять избыточные ресурсы ЦП, памяти или и то, и другое.» Отправка фрейма PING заставляет одноранговый узел ответить подтверждением PING (указывается флагом ACK). Это позволяет проверять активность HTTP-соединения, а также измерять время кругового задержки на 7-м уровне — обе полезные функции. Однако требование подтверждать PING создаёт потенциальный вектор атаки, поскольку генерирует работу для узла.
Клиент, который отправляет PING на edge-серверы Cloudflare слишком часто, вызовет срабатывание наших мер защиты от CVE-2019-9512, что заставит нас закрыть соединение. Вскоре после того, как мы запустили поддержку gRPC в 2020 году, мы столкнулись с проблемами совместимости с некоторыми gRPC-клиентами, которые отправляли много PING-запросов в рамках оптимизации производительности для настройки окна. Мы также обнаружили, что в крейте Rust Hyper была функция под названием Adaptive Window, которая повторяла эту конструкцию и вызывала схожую проблему, пока Hyper не внес исправление.
Решение загадки недопонимания между микросервисами
Когда появилось то сообщение с вопросом о том, какая часть нашего стека отвечает за отправку кода ошибки ENHANCE_YOUR_CALM, речь шла о клиенте, общающемся по HTTP/2 между двумя внутренними микросервисами.
Мы подозревали, что это проблема с механизмом защиты HTTP/2, и подтвердили в наших логах, что это была защита от PING-флуда. Но сделав шаг назад, вы можете задаться вопросом, почему два внутренних микросервиса вообще общаются через edge-сеть Cloudflare и, следовательно, попадают под наши защитные меры. В данном случае общение через edge предоставляет нам несколько преимуществ:
-
Мы получаем возможность «применить собачий корм» (dogfood) к нашей собственной edge-инфраструктуре и обнаруживать подобные проблемы!
-
Мы можем использовать Cloudflare Access для аутентификации. Это позволяет нашим микросервисам быть доступными безопасно как для других сервисов (с использованием сервисных токенов), так и для инженеров (что бесценно для отладки).
-
Внутренним сервисам, написанным на Cloudflare Workers, легко общаться с сервисами, доступными на edge.
Вопрос оставался: почему этот клиент вёл себя таким образом? Мы обменялись идеями, пытаясь докопаться до сути проблемы.
У клиента была конфигурация, которая указывала на то, что ему не нужно отправлять PING очень часто:
t2.PingTimeout = 2 * time.Second
t2.ReadIdleTimeout = 5 * time.Second
Однако в таких ситуациях обычно полезно установить «истину в последней инстанции» о том, что на самом деле происходит «в проводах». Например, захват дампа трафика, который можно анализировать в Wireshark, может предоставить неопровержимые доказательства того, что именно было отправлено по сети. Следующий лучший вариант — детальное/трассировочное логирование на стороне отправителя или получателя, хотя иногда логи могут вводить в заблуждение, так что caveat emptor («покупатель, будь осторожен»).
В нашем конкретном случае было проще использовать логирование с GODEBUG=http2debug=2. Мы создали упрощённую минимальную репродукцию клиента, которая вызывала ошибку, чтобы исключить другие потенциальные переменные. Мы провели анализ логов в группе, совмещённый с погружением в код стандартной библиотеки Go, чтобы понять, что она на самом деле делает. Исааку Азимову обычно приписывают цитату: «Самая захватывающая фраза в науке, та, что возвещает о новых открытиях, — не "Эврика!", а "Это забавно..."», и, конечно же, в течение часа кто-то заявил–
забавная часть, которую я вижу, это:
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote RST_STREAM stream=9 len=4 ErrCode=CANCEL
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote PING len=8 ping="jxe7xd6Rxdawxf8+"
каждый ping, кажется, предваряется RST_STREAM
Внимательные читатели вспомнят более раннее упоминание о Rapid Reset. Однако наши логи чётко указывали, что ENHANCE_YOUR_CALM срабатывал именно из-за PING-флуда. Небольшой поиск привёл нас к этой ветке рассылки и комментарию: «Отправка фрейма PING вместе с RST_STREAM позволяет клиенту различать неотзывчивый сервер и медленный ответ.» Это показалось весьма relevant. Мы также нашли внесённое изменение, связанное с этой темой. Это частично объяснило, почему было так много PING-запросов, но также подняло новый вопрос: почему так много сбросов потоков? Итак, мы вернулись к логам и собрали немного больше контекста о взаимодействии:
2025/09/02 17:33:18 http2: Transport received DATA flags=END_STREAM stream=47 len=0 data=""
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote RST_STREAM stream=47 len=4 ErrCode=CANCEL
2025/09/02 17:33:18 http2: Framer 0x14000624540: wrote PING len=8 ping="x97Wx02xfa>xa8xabi"
Интересно здесь то, что сервер отправил фрейм DATA с установленным флагом END_STREAM. Согласно машине состояний потока HTTP/2, поток должен был перейти в состояние closed при обработке фрейма с END_STREAM. Клиенту в этом состоянии не нужно ничего делать — отправка RST_STREAM совершенно не нужна.
Ещё немного покопавшись и поразмыслив, один инженер воскликнул:
Я заметил, что сброс+ping происходит только когда вы вызываете resp.Body.Close()
Я считаю, что HTTP-библиотека Go на самом деле не читает тело ответа автоматически, а держит поток открытым для вашего использования до тех пор, пока вы не вызовете resp.Body.Close(), что вы можете сделать в любой удобный для вас момент.
Забавный момент в нашем примере заключался в том, что там не было вообще никакого HTTP-тела для чтения. Из предыдущего примера: received DATA flags=END_STREAM stream=47 len=0 data="".
Наука и инженерия временами бывают странными и контрintuitive. Мы решили изменить нашего клиента, чтобы он читал (отсутствующее) тело через io.Copy(io.Discard, resp.Body) перед его закрытием.
И, конечно же, это немедленно остановило отправку клиентом как бесполезного RST_STREAM, так и, по ассоциации, фрейма PING.
Загадка раскрыта?
Чтобы доказать, что мы устранили корневую причину, клиент в продакшене был обновлен аналогичным исправлением. Через несколько часов все закрытия с ENHANCE_YOUR_CALM были устранены.
Чтение тел ответов в Go может быть неинтуитивным
Стоит отметить, что в некоторых ситуациях обеспечение постоянного чтения тела ответа в Go может быть неочевидным. Например, на первый взгляд кажется, что тело ответа всегда будет прочитано в следующем примере:
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return err
}
Однако json.Decoder прекращает чтение, как только находит полный JSON-документ или возникает ошибка. Если тело ответа содержит несколько JSON-документов или некорректный JSON, то всё тело ответа может всё равно не быть прочитано полностью.
Поэтому в наших клиентах мы начали заменять defer response.Body.Close() на следующий шаблон, чтобы гарантировать полное чтение тел ответов:
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return err
}
Действия при столкновении с ENHANCE_YOUR_CALM
HTTP/2 — это протокол с несколькими функциями. Многие реализации включают механизмы защиты от неправильного использования функций, что может привести к закрытию соединения. Рекомендуемым кодом ошибки для закрытия соединений в таких условиях является ENHANCE_YOUR_CALM. Существует множество реализаций и API HTTP/2, которые могут использовать функции HTTP/2 неожиданными способами, похожими на атаки.
Если ваш HTTP/2 клиент сталкивается с закрытиями с ENHANCE_YOUR_CALM, мы рекомендуем установить точную причину с помощью захвата пакетов (включая ключи дешифрования TLS через механизмы вроде SSLKEYLOGFILE) и/или детального логирования трассировки. Ищите шаблоны частых или повторяющихся фреймов, которые могут быть похожи на вредоносный трафик. Изменение вашего клиента может помочь избежать его ошибочной классификации как атакующего.
Если вы используете Go, мы рекомендуем всегда читать тела ответов HTTP/2 (даже пустые), чтобы избежать отправки ненужных фреймов RST_STREAM и PING. Это особенно важно, если вы используете одно соединение для множества запросов, что может вызывать высокую частоту этих фреймов.
Это также стало хорошим напоминанием о преимуществах использования наших собственных продуктов внутри наших внутренних сервисов. Когда мы сталкиваемся с такими проблемами, наши уроки могут помочь клиентам с похожими настройками.