From dcebebcfb3a1b36ecd2bfd375fca49c13e9a08b4 Mon Sep 17 00:00:00 2001 From: Tim van der Lippe Date: Wed, 12 Aug 2026 12:00:43 +0200 Subject: [PATCH] Support all whitespace characters in header values This PR is part of a series of PRs across several crates, to eventually fix web-platform-test failures for Servo. Servo currently fails [`/content-security-policy/generic/only-valid-whitespaces-are-allowed.html`](https://wpt.fyi/results/content-security-policy/generic/only-valid-whitespaces-are-allowed.html?product=servo) which is a test that checks whether various whitespace characters are properly parsed in the `Content-Security-Policy` header. Currently, Servo times out on this test, since it attempts to send a HTTP request with Hyper. Hyper calls `httparse` to parse the response returned by the test server with, which includes the `Content-Security-Policy` with the special whitespace character. When `httparse` attempts to parse this, it fails since it contains characters it currently does not accept. While RFC7230 (HTTP) does not allow such characters to be present, for web browsers [section 3.5](https://datatracker.ietf.org/doc/html/rfc7230#section-3.5) is relevant. That section states that for historical reasons (which is the case for web browsers), both `%x0B` and `%x0C` MAY be treated as regular space characters. Since currently `httparse` rejects parsing, Servo is unable to implement that requirement. To fix this issue, the following changes are made: 1. In `httparse`, support parsing `%x0B` and `%x0C` in header values 2. In `http`, consider `%x0B` and `%x0C` as opaque bytes for byte constructors 3. In `hyper`, use `HeaderValue::from_bytes` rather than the generic `HeaderValue::from_maybe_shared_unchecked` to operate on the raw bytes 4. In `content-security-policy` update policy parsing to check if directive value is an ASCII string. It already handles `%x0B` and `%x0C` in `is_char_ascii_whitespace`, but with the HTTP changes it exposed this other missing check 5. In Servo, replace `header.to_str()` with `str::from_utf8(header.as_bytes())` --- Cargo.toml | 4 ++++ src/proto/h1/role.rs | 15 ++++++--------- tests/client.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 22826d9900..501d09bdcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ smallvec = { version = "1.12", features = ["const_generics", "const_new"], optio tracing = { version = "0.1", default-features = false, features = ["std"], optional = true } want = { version = "0.3", optional = true } +[patch.crates-io] +http = { git = "https://github.com/TimvdLippe/rust-http/", branch = "whitespace-characters" } +httparse = { git = "https://github.com/TimvdLippe/rust-httparse", branch = "whitespace-characters" } + [dev-dependencies] form_urlencoded = "1" futures-channel = { version = "0.3", features = ["sink"] } diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 29bcd44b0d..0579e09581 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -47,13 +47,10 @@ macro_rules! header_name { macro_rules! header_value { ($bytes:expr) => {{ { - // unsafe used because of the call of `HeaderValue::from_maybe_shared_unchecked`. - // SAFETY: - // 1. The input `$bytes` must be a valid header value as per RFC 7230. - // 2. Specifically, it must not contain any prohibited characters (like `\r`, `\n`, or non-visible ASCII characters outside of allowed ranges). - // 3. This is safe because the caller is responsible for ensuring the byte content - // has been validated or is known to be a constant/static valid header value. - unsafe { HeaderValue::from_maybe_shared_unchecked($bytes) } + match HeaderValue::from_bytes($bytes) { + Ok(name) => name, + Err(e) => maybe_panic!(e), + } } }}; } @@ -263,7 +260,7 @@ impl Http1Transaction for Server { // SAFETY: array is valid up to `headers_len` let header = unsafe { header.assume_init_ref() }; let name = header_name!(&slice[header.name.0..header.name.1]); - let value = header_value!(slice.slice(header.value.0..header.value.1)); + let value = header_value!(&slice.slice(header.value.0..header.value.1)); match name { header::TRANSFER_ENCODING => { @@ -1120,7 +1117,7 @@ impl Http1Transaction for Client { // SAFETY: array is valid up to `headers_len` let header = unsafe { header.assume_init_ref() }; let name = header_name!(&slice[header.name.0..header.name.1]); - let value = header_value!(slice.slice(header.value.0..header.value.1)); + let value = header_value!(&slice.slice(header.value.0..header.value.1)); if let header::CONNECTION = name { // keep_alive was previously set to default for Version diff --git a/tests/client.rs b/tests/client.rs index b512260cc5..f74cd59180 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -1558,6 +1558,35 @@ test! { body: None, } +test! { + name: client_handle_whitespace_characters, + + server: + expected: "\ + GET / HTTP/1.1\r\n\ + host: {addr}\r\n\ + \r\n\ + ", + reply: "\ + HTTP/1.1 200 OK\r\n\ + Content-Length: 0\r\n\ + Content-Security-Policy: img-src\x0B'none'; default-src\x0C'none';\r\n\ + \r\n\ + ", + + client: + request: { + method: GET, + url: "http://{addr}/", + }, + response: + status: OK, + headers: { + "content-security-policy" => "img-src\x0B'none'; default-src\x0C'none';", + }, + body: None, +} + mod conn { use std::error::Error; use std::io::{self, Read, Write};