fix(fetch): match Chromium for bare-CR header EOF

This commit is contained in:
ldm0
2026-10-22 02:33:44 +08:00
committed by Donough Liu
parent 87527dec85
commit ce7f503f28
4 changed files with 163 additions and 5 deletions
Generated
+2 -2
View File
@@ -946,7 +946,7 @@ dependencies = [
[[package]]
name = "curl"
version = "0.4.51"
source = "git+https://github.com/lexmount/curl-rust?rev=79310cbd939be3548b78d0b23ab1d60d5facdeec#79310cbd939be3548b78d0b23ab1d60d5facdeec"
source = "git+https://github.com/lexmount/curl-rust?rev=0392e12475d8d1160d4cae07c172cac02797cc25#0392e12475d8d1160d4cae07c172cac02797cc25"
dependencies = [
"curl-sys",
"libc",
@@ -960,7 +960,7 @@ dependencies = [
[[package]]
name = "curl-sys"
version = "0.4.91+curl-8.23.0"
source = "git+https://github.com/lexmount/curl-rust?rev=79310cbd939be3548b78d0b23ab1d60d5facdeec#79310cbd939be3548b78d0b23ab1d60d5facdeec"
source = "git+https://github.com/lexmount/curl-rust?rev=0392e12475d8d1160d4cae07c172cac02797cc25#0392e12475d8d1160d4cae07c172cac02797cc25"
dependencies = [
"brotlic-sys",
"cc",
+2 -2
View File
@@ -107,8 +107,8 @@ subsetter = { git = "https://github.com/lexmount/subsetter", rev = "8d3815245835
url = { git = "https://github.com/lexmount/rust-url", rev = "ca9fcad2b99f20c30c28a618ad78410f6fd31161" }
idna = { git = "https://github.com/lexmount/rust-url", rev = "ca9fcad2b99f20c30c28a618ad78410f6fd31161" }
xml5ever = { git = "https://github.com/lexmount/html5ever", rev = "4574311c63e79bba7d4ddb98cb0562d8dad499ee" }
curl = { git = "https://github.com/lexmount/curl-rust", rev = "79310cbd939be3548b78d0b23ab1d60d5facdeec" }
curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "79310cbd939be3548b78d0b23ab1d60d5facdeec" }
curl = { git = "https://github.com/lexmount/curl-rust", rev = "0392e12475d8d1160d4cae07c172cac02797cc25" }
curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "0392e12475d8d1160d4cae07c172cac02797cc25" }
cookie = { git = "https://github.com/ldm0/cookie-rs", branch = "priority" }
v8 = { path = "vendor/v8-152.2.0" }
deno_v8 = { path = "vendor/deno_v8-0.3.0" }
+158
View File
@@ -352,3 +352,161 @@ async fn header_eof_does_not_publish_a_cancelled_response() -> Result<()> {
assert!(client.shutdown().is_clean());
Ok(())
}
#[tokio::test]
async fn bare_cr_header_eof_matches_chromium_after_final_and_informational_headers() -> Result<()> {
for prefix in [b"".as_slice(), b"HTTP/1.1 100 Continue\r\n\r\n"] {
for byte in [b'X', b':', b'\t', b'\r', 0xff] {
let response = [
prefix,
b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nX-Test: retained\r\n\r",
&[byte, b'\n'],
b"abc",
]
.concat();
let listener = TcpListener::bind("127.0.0.1:0").await?;
let url = format!("http://{}/bare-cr-eof", listener.local_addr()?);
let server = tokio::spawn(serve_responses(listener, vec![response; 3]));
let client =
FetchClient::new(&FetchConfig::default(), new_shared_browser_cookie_store());
for mode in ["buffered", "html", "raw"] {
let (head, body) = fetch_in_mode(&client, Request::get(&url)?, mode)
.await
.with_context(|| format!("{mode}, prefix={prefix:?}, byte={byte:#x}"))?;
assert_eq!(head.status, 200, "{mode}");
assert!(
head.headers
.contains(&("content-length".into(), b"3".to_vec()))
);
assert!(
head.headers
.contains(&("x-test".into(), b"retained".to_vec()))
);
// Chromium consumes these bytes as an unterminated header block.
assert!(body.is_empty(), "{mode}: {body:?}");
}
assert_eq!(server.await??.len(), 3);
assert!(client.shutdown().is_clean());
}
}
Ok(())
}
#[tokio::test]
async fn header_eof_rejects_nul_in_a_pending_field_on_every_transport() -> Result<()> {
for suffix in [b"X-Test: one\0two".as_slice(), b"\r\0\n"] {
let response = [
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n".as_slice(),
suffix,
]
.concat();
let listener = TcpListener::bind("127.0.0.1:0").await?;
let url = format!("http://{}/nul-eof", listener.local_addr()?);
let server = tokio::spawn(serve_responses(listener, vec![response; 3]));
let client = FetchClient::new(&FetchConfig::default(), new_shared_browser_cookie_store());
for mode in ["buffered", "html", "raw"] {
let request =
Request::get(&url)?.with_browser_request_metadata(BrowserRequestMetadata::Fetch);
assert!(
fetch_in_mode(&client, request, mode).await.is_err(),
"{mode}"
);
}
assert_eq!(server.await??.len(), 3);
assert!(client.shutdown().is_clean());
}
Ok(())
}
#[tokio::test]
async fn bounded_leading_cr_status_lines_work_after_informational_responses() -> Result<()> {
for prefix in ["", "HTTP/1.1 100 Continue\r\n\r\n"] {
for count in 1..=5 {
let response = format!(
"{prefix}{}HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc",
"\r".repeat(count)
);
let listener = TcpListener::bind("127.0.0.1:0").await?;
let url = format!("http://{}/status-slop", listener.local_addr()?);
let server = tokio::spawn(serve_responses(listener, vec![response.into_bytes(); 3]));
let client =
FetchClient::new(&FetchConfig::default(), new_shared_browser_cookie_store());
for mode in ["buffered", "html", "raw"] {
let request = Request::get(&url)?
.with_browser_request_metadata(BrowserRequestMetadata::Fetch);
let result = fetch_in_mode(&client, request, mode).await;
if count <= 4 {
let (head, body) = result.with_context(|| format!("{mode}, count={count}"))?;
assert_eq!(head.status, 200);
assert_eq!(body, b"abc");
} else {
assert!(result.is_err(), "{mode}: status slop exceeds four bytes");
}
}
assert_eq!(server.await??.len(), 3);
assert!(client.shutdown().is_clean());
}
}
Ok(())
}
#[tokio::test]
async fn https_header_eof_is_rejected_while_bare_cr_body_bytes_are_preserved() -> Result<()> {
let cases = [
(
b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\n\rX\n".as_slice(),
true,
),
(
b"HTTP/1.1 200 OK\r\nX-Test: truncated\r\n".as_slice(),
false,
),
(
b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\rX\nabc".as_slice(),
false,
),
];
let listener = TcpListener::bind("127.0.0.1:0").await?;
let url = format!("https://{}/eof", listener.local_addr()?);
let server = tokio::spawn(async move {
let acceptor = super::support::http11_tls_acceptor();
for (response, _) in cases.iter().cycle().take(cases.len() * 3) {
let (stream, _) =
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await??;
let mut stream = acceptor.accept(stream).await?;
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
let mut byte = [0];
stream.read_exact(&mut byte).await?;
request.push(byte[0]);
}
stream.write_all(response).await?;
stream.shutdown().await?;
}
Ok::<_, anyhow::Error>(())
});
let mut config = FetchConfig::default();
config.set_tls_verify_host(false);
let client = FetchClient::new(&config, new_shared_browser_cookie_store());
for mode in ["buffered", "html", "raw"] {
for (_, succeeds) in cases {
let request =
Request::get(&url)?.with_browser_request_metadata(BrowserRequestMetadata::Fetch);
let result = fetch_in_mode(&client, request, mode).await;
if succeeds {
let (head, body) =
result.with_context(|| format!("{mode}: complete TLS response"))?;
assert_eq!(head.status, 200);
assert_eq!(body, b"\rX\n");
} else {
assert!(
result.is_err(),
"{mode}: HTTPS must reject incomplete headers"
);
}
}
}
server.await??;
assert!(client.shutdown().is_clean());
Ok(())
}
+1 -1
View File
@@ -844,7 +844,7 @@ fn http2_and_http11_tls_acceptor() -> tokio_rustls::TlsAcceptor {
tokio_rustls::TlsAcceptor::from(Arc::new(config))
}
fn http11_tls_acceptor() -> tokio_rustls::TlsAcceptor {
pub(super) fn http11_tls_acceptor() -> tokio_rustls::TlsAcceptor {
use tokio_rustls::rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},