From a04dd3626d505447fe35a0e75a17f4e324c271f0 Mon Sep 17 00:00:00 2001 From: Kirill Minovsky Date: Fri, 25 Sep 2026 03:46:54 +0300 Subject: [PATCH] feat(ext): make HeaderCaseMap public `HeaderCaseMap` was internal, so a message could only carry the original casing of names hyper itself had parsed, and only with `preserve_header_case` turned on. A client that has to write a name the way some other program writes it, because a server compares the bytes it receives, had no way to say so: `http::HeaderName` lowercases the name, and the HTTP/1 encoder writes that lowercased name, so the request no longer matches the client being imitated. Names such as `TE` next to a lowercased `sec-ch-ua`, or a title-cased `Sec-Fetch-Site`, are sent in the casing a browser sends them by. The type is public now, with a `Default` impl and a public `append` that records the spelling a name was written with. Putting the map into a message's extensions makes the HTTP/1 encoder write the spellings it holds, and write the lowercased `HeaderName` for a name the map does not mention, which is the behavior it already had for messages hyper parsed. No public way to read the spellings back is added here; that part of the design is still open. Refs: #2695 --- src/ext/mod.rs | 86 ++++++++++++++++++++++++++++++++++++++++---- src/proto/h1/role.rs | 40 +++++++++++++++++++++ 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/ext/mod.rs b/src/ext/mod.rs index ba6886b835..7b641573d2 100644 --- a/src/ext/mod.rs +++ b/src/ext/mod.rs @@ -156,9 +156,47 @@ impl fmt::Debug for Protocol { /// ``` /// /// [`preserve_header_case`]: /client/struct.Client.html#method.preserve_header_case +/// +/// # Writing a message with a chosen casing +/// +/// A client that has to write a header name the way some other program writes +/// it, because a server compares the bytes it receives. For example, `TE` +/// rather than the lowercased `te`, or `sec-ch-ua` lowercased right next to a +/// title-cased `Sec-Fetch-Site`. Insert a map like that into the message's +/// extensions, and the HTTP/1 encoder writes each spelling it finds; a name the +/// map does not mention is written as the lowercased [`HeaderName`]. +/// +/// ```rust +/// use http::header::{HeaderName, HeaderValue, TE}; +/// use hyper::body::Bytes; +/// use hyper::ext::HeaderCaseMap; +/// +/// let sec_fetch_site = HeaderName::from_static("sec-fetch-site"); +/// +/// let mut req = http::Request::new(()); +/// req.headers_mut() +/// .insert(TE, HeaderValue::from_static("trailers")); +/// req.headers_mut() +/// .insert(sec_fetch_site.clone(), HeaderValue::from_static("cross-site")); +/// +/// let mut case_map = HeaderCaseMap::default(); +/// case_map.append(TE, Bytes::from_static(b"TE")); +/// case_map.append(sec_fetch_site, Bytes::from_static(b"Sec-Fetch-Site")); +/// +/// req.extensions_mut().insert(case_map); +/// ``` #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] #[derive(Clone, Debug)] -pub(crate) struct HeaderCaseMap(HeaderMap); +pub struct HeaderCaseMap(HeaderMap); + +#[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] +impl Default for HeaderCaseMap { + /// An empty map: every header name is written as the lowercased + /// [`HeaderName`] it is. + fn default() -> Self { + Self(HeaderMap::default()) + } +} #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] impl HeaderCaseMap { @@ -179,18 +217,20 @@ impl HeaderCaseMap { self.0.get_all(name).into_iter() } - #[cfg(any(feature = "client", feature = "server"))] - pub(crate) fn default() -> Self { - Self(HeaderMap::default()) - } - #[cfg(any(test, feature = "ffi"))] pub(crate) fn insert(&mut self, name: HeaderName, orig: Bytes) { self.0.insert(name, orig); } + /// Records that `name` was written as `orig`, so that the encoder writes + /// `orig` when this map is in a message's extensions and the message goes + /// out as HTTP/1. `name` is the lowercased [`HeaderName`] the spelling is + /// looked up by; `orig` goes to the wire verbatim and must be a legal header + /// name equal to `name` ignoring case. Several spellings for one name are + /// kept in the order appended and paired with that header's values in the + /// same order, the lowercased name standing in for a value left over. #[cfg(any(feature = "client", feature = "server"))] - pub(crate) fn append(&mut self, name: N, orig: Bytes) + pub fn append(&mut self, name: N, orig: Bytes) where N: IntoHeaderName, { @@ -261,3 +301,35 @@ impl OriginalHeaderOrder { self.entry_order.iter() } } + +#[cfg(all(test, any(feature = "client", feature = "server"), feature = "http1"))] +mod tests { + use super::*; + + #[test] + fn test_appended_spellings_that_differ_only_in_case_are_distinct() { + let mut case_map = HeaderCaseMap::default(); + + assert_eq!( + case_map + .get_all_internal(&HeaderName::from_static("x-bread")) + .count(), + 0 + ); + + case_map.append( + HeaderName::from_bytes(b"x-Bread").expect("valid header name"), + Bytes::from_static(b"x-Bread"), + ); + case_map.append( + HeaderName::from_bytes(b"X-BREAD").expect("valid header name"), + Bytes::from_static(b"X-BREAD"), + ); + + let spellings: Vec<&[u8]> = case_map + .get_all_internal(&HeaderName::from_static("x-bread")) + .map(AsRef::as_ref) + .collect(); + assert_eq!(spellings, [b"x-Bread".as_slice(), b"X-BREAD".as_slice()]); + } +} diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index b83c8ab12b..4e1c89a091 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -2615,6 +2615,46 @@ mod tests { .as_ref(), ); } + + #[test] + fn test_client_request_encode_public_case_map() { + let sec_fetch_site = HeaderName::from_static("sec-fetch-site"); + + let mut head = MessageHead::default(); + head.headers + .insert(&sec_fetch_site, HeaderValue::from_static("cross-site")); + head.headers.insert( + &HeaderName::from_static("te"), + HeaderValue::from_static("trailers"), + ); + + let mut case_map = HeaderCaseMap::default(); + case_map.append(&sec_fetch_site, Bytes::from_static(b"Sec-Fetch-Site")); + case_map.append("te", Bytes::from_static(b"TE")); + head.extensions.insert(case_map); + + let mut vec = Vec::new(); + Client::encode( + Encode { + head: &mut head, + body: None, + #[cfg(feature = "server")] + keep_alive: true, + req_method: &mut None, + title_case_headers: false, + #[cfg(feature = "server")] + date_header: true, + }, + &mut vec, + ) + .unwrap(); + + assert_eq!( + &*vec, + b"GET / HTTP/1.1\r\nSec-Fetch-Site: cross-site\r\nTE: trailers\r\n\r\n".as_ref(), + ); + } + #[test] fn test_client_request_encode_orig_and_title_case() { use crate::proto::BodyLength;