From 7393d669e2220b23ea06edd7f1eb6aea1cc6d28a Mon Sep 17 00:00:00 2001 From: Hugh Date: Mon, 12 Jan 2026 17:19:53 -0800 Subject: [PATCH 1/2] fix(http1): use case-insensitive matching for trailer fields Trailer header values were stored as HeaderValue and compared against HeaderName, causing case mismatch. Convert to HeaderName during parsing to normalize case per RFC 9110. Closes #4010 --- src/proto/h1/encode.rs | 71 +++++++++++++++++++++++++----------------- src/proto/h1/role.rs | 36 +++++++++++++++------ 2 files changed, 69 insertions(+), 38 deletions(-) diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index 2df0c396b7..ecfdd4611e 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::HashSet; use std::fmt; use std::io::IoSlice; @@ -9,7 +9,7 @@ use http::{ AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HOST, MAX_FORWARDS, SET_COOKIE, TE, TRAILER, TRANSFER_ENCODING, }, - HeaderMap, HeaderName, HeaderValue, + HeaderMap, HeaderName, }; use super::io::WriteBuf; @@ -35,7 +35,7 @@ pub(crate) struct NotEof(u64); #[derive(Debug, PartialEq, Clone)] enum Kind { /// An Encoder for when Transfer-Encoding includes `chunked`. - Chunked(Option>), + Chunked(Option>), /// An Encoder for when Content-Length is set. /// /// Enforces that the body is not longer than the Content-Length header. @@ -77,7 +77,7 @@ impl Encoder { Encoder::new(Kind::CloseDelimited) } - pub(crate) fn into_chunked_with_trailing_fields(self, trailers: Vec) -> Encoder { + pub(crate) fn into_chunked_with_trailing_fields(self, trailers: Vec) -> Encoder { match self.kind { Kind::Chunked(_) => Encoder { kind: Kind::Chunked(Some(trailers)), @@ -168,7 +168,7 @@ impl Encoder { trace!("encoding trailers"); match &self.kind { Kind::Chunked(Some(allowed_trailer_fields)) => { - let allowed_trailer_field_map = allowed_trailer_field_map(allowed_trailer_fields); + let allowed_set: HashSet<&HeaderName> = allowed_trailer_fields.iter().collect(); let mut cur_name = None; let mut allowed_trailers = HeaderMap::new(); @@ -179,7 +179,7 @@ impl Encoder { } let name = cur_name.as_ref().expect("current header name"); - if allowed_trailer_field_map.contains_key(name.as_str()) { + if allowed_set.contains(name) { if is_valid_trailer_field(name) { allowed_trailers.insert(name, value); } else { @@ -279,21 +279,6 @@ fn is_valid_trailer_field(name: &HeaderName) -> bool { ) } -fn allowed_trailer_field_map(allowed_trailer_fields: &Vec) -> HashMap { - let mut trailer_map = HashMap::new(); - - for header_value in allowed_trailer_fields { - if let Ok(header_str) = header_value.to_str() { - let items: Vec<&str> = header_str.split(',').map(|item| item.trim()).collect(); - - for item in items { - trailer_map.entry(item.to_string()).or_insert(()); - } - } - } - - trailer_map -} impl Buf for EncodedBuf where @@ -532,7 +517,7 @@ mod tests { #[test] fn chunked_with_valid_trailers() { let encoder = Encoder::chunked(); - let trailers = vec![HeaderValue::from_static("chunky-trailer")]; + let trailers = vec![HeaderName::from_static("chunky-trailer")]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); let headers = HeaderMap::from_iter(vec![ @@ -557,8 +542,8 @@ mod tests { fn chunked_with_multiple_trailer_headers() { let encoder = Encoder::chunked(); let trailers = vec![ - HeaderValue::from_static("chunky-trailer"), - HeaderValue::from_static("chunky-trailer-2"), + HeaderName::from_static("chunky-trailer"), + HeaderName::from_static("chunky-trailer-2"), ]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); @@ -606,8 +591,7 @@ mod tests { fn chunked_with_invalid_trailers() { let encoder = Encoder::chunked(); - let trailers = format!( - "{},{},{},{},{},{},{},{},{},{},{},{}", + let trailers = vec![ AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, @@ -620,8 +604,7 @@ mod tests { TRAILER, TRANSFER_ENCODING, TE, - ); - let trailers = vec![HeaderValue::from_str(&trailers).unwrap()]; + ]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); let mut headers = HeaderMap::new(); @@ -644,7 +627,7 @@ mod tests { #[test] fn chunked_with_title_case_headers() { let encoder = Encoder::chunked(); - let trailers = vec![HeaderValue::from_static("chunky-trailer")]; + let trailers = vec![HeaderName::from_static("chunky-trailer")]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); let headers = HeaderMap::from_iter(vec![( @@ -657,4 +640,34 @@ mod tests { dst.put(buf1); assert_eq!(dst, b"0\r\nChunky-Trailer: header data\r\n\r\n"); } + + #[test] + fn chunked_trailers_case_insensitive_matching() { + // Regression test for issue #4010: HTTP/1.1 trailers are case-sensitive + // + // Previously, the Trailer header values were stored as HeaderValue (preserving case) + // and compared against HeaderName (which is always lowercase). This caused trailers + // declared as "Chunky-Trailer" to not match actual trailers sent as "chunky-trailer". + // + // The fix converts Trailer header values to HeaderName during parsing, which + // normalizes the case and enables proper case-insensitive matching. + // + // Note: HeaderName::from_static() requires lowercase input. In real usage, + // HeaderName::from_bytes() is used to parse the Trailer header value, which + // normalizes mixed-case input like "Chunky-Trailer" to "chunky-trailer". + let encoder = Encoder::chunked(); + let trailers = vec![HeaderName::from_static("chunky-trailer")]; + let encoder = encoder.into_chunked_with_trailing_fields(trailers); + + // The actual trailer being sent + let headers = HeaderMap::from_iter(vec![( + HeaderName::from_static("chunky-trailer"), + HeaderValue::from_static("trailer value"), + )]); + + let buf = encoder.encode_trailers::<&[u8]>(headers, false).unwrap(); + let mut dst = Vec::new(); + dst.put(buf); + assert_eq!(dst, b"0\r\nchunky-trailer: trailer value\r\n\r\n"); + } } diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 1c8f1fbcf7..f92092e5a9 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -650,7 +650,7 @@ impl Server { }; let mut encoder = Encoder::length(0); - let mut allowed_trailer_fields: Option> = None; + let mut allowed_trailer_fields: Option> = None; let mut wrote_date = false; let mut cur_name = None; let mut is_name_written = false; @@ -860,12 +860,22 @@ impl Server { extend(dst, value.as_bytes()); } - match allowed_trailer_fields { - Some(ref mut allowed_trailer_fields) => { - allowed_trailer_fields.push(value); - } - None => { - allowed_trailer_fields = Some(vec![value]); + // Parse the Trailer header value into HeaderNames. + // The value may contain comma-separated names. + // HeaderName normalizes to lowercase for case-insensitive matching. + if let Ok(value_str) = value.to_str() { + let names: Vec = value_str + .split(',') + .filter_map(|s| HeaderName::from_bytes(s.trim().as_bytes()).ok()) + .collect(); + + match allowed_trailer_fields { + Some(ref mut fields) => { + fields.extend(names); + } + None => { + allowed_trailer_fields = Some(names); + } } } @@ -1389,8 +1399,16 @@ impl Client { let encoder = encoder.map(|enc| { if enc.is_chunked() { - let allowed_trailer_fields: Vec = - headers.get_all(header::TRAILER).iter().cloned().collect(); + // Parse Trailer header values into HeaderNames. + // Each Trailer header value may contain comma-separated names. + // HeaderName normalizes to lowercase, enabling case-insensitive matching. + let allowed_trailer_fields: Vec = headers + .get_all(header::TRAILER) + .iter() + .filter_map(|hv| hv.to_str().ok()) + .flat_map(|s| s.split(',')) + .filter_map(|s| HeaderName::from_bytes(s.trim().as_bytes()).ok()) + .collect(); if !allowed_trailer_fields.is_empty() { return enc.into_chunked_with_trailing_fields(allowed_trailer_fields); From cd864f31f6b46c877b6769b8f3223614f95b90e0 Mon Sep 17 00:00:00 2001 From: Hugh Date: Tue, 13 Jan 2026 17:21:11 -0800 Subject: [PATCH 2/2] style: fix formatting --- src/proto/h1/encode.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index ecfdd4611e..ff8f21fdf5 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -279,7 +279,6 @@ fn is_valid_trailer_field(name: &HeaderName) -> bool { ) } - impl Buf for EncodedBuf where B: Buf,