Skip to content

Commit 3b344ca

Browse files
HueCodespborzenkov
andauthored
fix(http1): use case-insensitive matching for trailer fields (#4011)
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 Co-authored-by: Hugh <HueCodes@users.noreply.github.com> Co-authored-by: Pavel Borzenkov <pavel@borzenkov.net>
1 parent 0f0b6ed commit 3b344ca

4 files changed

Lines changed: 188 additions & 39 deletions

File tree

src/proto/h1/encode.rs

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use std::collections::HashSet;
22
use std::fmt;
33
use std::io::IoSlice;
44

@@ -9,7 +9,7 @@ use http::{
99
AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE,
1010
CONTENT_TYPE, HOST, MAX_FORWARDS, SET_COOKIE, TE, TRAILER, TRANSFER_ENCODING,
1111
},
12-
HeaderMap, HeaderName, HeaderValue,
12+
HeaderMap, HeaderName,
1313
};
1414

1515
use super::io::WriteBuf;
@@ -35,7 +35,7 @@ pub(crate) struct NotEof(u64);
3535
#[derive(Debug, PartialEq, Clone)]
3636
enum Kind {
3737
/// An Encoder for when Transfer-Encoding includes `chunked`.
38-
Chunked(Option<Vec<HeaderValue>>),
38+
Chunked(Option<Vec<HeaderName>>),
3939
/// An Encoder for when Content-Length is set.
4040
///
4141
/// Enforces that the body is not longer than the Content-Length header.
@@ -77,7 +77,7 @@ impl Encoder {
7777
Encoder::new(Kind::CloseDelimited)
7878
}
7979

80-
pub(crate) fn into_chunked_with_trailing_fields(self, trailers: Vec<HeaderValue>) -> Encoder {
80+
pub(crate) fn into_chunked_with_trailing_fields(self, trailers: Vec<HeaderName>) -> Encoder {
8181
match self.kind {
8282
Kind::Chunked(_) => Encoder {
8383
kind: Kind::Chunked(Some(trailers)),
@@ -168,7 +168,7 @@ impl Encoder {
168168
trace!("encoding trailers");
169169
match &self.kind {
170170
Kind::Chunked(Some(allowed_trailer_fields)) => {
171-
let allowed_trailer_field_map = allowed_trailer_field_map(allowed_trailer_fields);
171+
let allowed_set: HashSet<&HeaderName> = allowed_trailer_fields.iter().collect();
172172

173173
let mut cur_name = None;
174174
let mut allowed_trailers = HeaderMap::new();
@@ -179,7 +179,7 @@ impl Encoder {
179179
}
180180
let name = cur_name.as_ref().expect("current header name");
181181

182-
if allowed_trailer_field_map.contains_key(name.as_str()) {
182+
if allowed_set.contains(name) {
183183
if is_valid_trailer_field(name) {
184184
allowed_trailers.insert(name, value);
185185
} else {
@@ -279,22 +279,6 @@ fn is_valid_trailer_field(name: &HeaderName) -> bool {
279279
)
280280
}
281281

282-
fn allowed_trailer_field_map(allowed_trailer_fields: &Vec<HeaderValue>) -> HashMap<String, ()> {
283-
let mut trailer_map = HashMap::new();
284-
285-
for header_value in allowed_trailer_fields {
286-
if let Ok(header_str) = header_value.to_str() {
287-
let items: Vec<&str> = header_str.split(',').map(|item| item.trim()).collect();
288-
289-
for item in items {
290-
trailer_map.entry(item.to_string()).or_insert(());
291-
}
292-
}
293-
}
294-
295-
trailer_map
296-
}
297-
298282
impl<B> Buf for EncodedBuf<B>
299283
where
300284
B: Buf,
@@ -532,7 +516,7 @@ mod tests {
532516
#[test]
533517
fn chunked_with_valid_trailers() {
534518
let encoder = Encoder::chunked();
535-
let trailers = vec![HeaderValue::from_static("chunky-trailer")];
519+
let trailers = vec![HeaderName::from_static("chunky-trailer")];
536520
let encoder = encoder.into_chunked_with_trailing_fields(trailers);
537521

538522
let headers = HeaderMap::from_iter(vec![
@@ -557,8 +541,8 @@ mod tests {
557541
fn chunked_with_multiple_trailer_headers() {
558542
let encoder = Encoder::chunked();
559543
let trailers = vec![
560-
HeaderValue::from_static("chunky-trailer"),
561-
HeaderValue::from_static("chunky-trailer-2"),
544+
HeaderName::from_static("chunky-trailer"),
545+
HeaderName::from_static("chunky-trailer-2"),
562546
];
563547
let encoder = encoder.into_chunked_with_trailing_fields(trailers);
564548

@@ -606,8 +590,7 @@ mod tests {
606590
fn chunked_with_invalid_trailers() {
607591
let encoder = Encoder::chunked();
608592

609-
let trailers = format!(
610-
"{},{},{},{},{},{},{},{},{},{},{},{}",
593+
let trailers = vec![
611594
AUTHORIZATION,
612595
CACHE_CONTROL,
613596
CONTENT_ENCODING,
@@ -620,8 +603,7 @@ mod tests {
620603
TRAILER,
621604
TRANSFER_ENCODING,
622605
TE,
623-
);
624-
let trailers = vec![HeaderValue::from_str(&trailers).unwrap()];
606+
];
625607
let encoder = encoder.into_chunked_with_trailing_fields(trailers);
626608

627609
let mut headers = HeaderMap::new();
@@ -644,7 +626,7 @@ mod tests {
644626
#[test]
645627
fn chunked_with_title_case_headers() {
646628
let encoder = Encoder::chunked();
647-
let trailers = vec![HeaderValue::from_static("chunky-trailer")];
629+
let trailers = vec![HeaderName::from_static("chunky-trailer")];
648630
let encoder = encoder.into_chunked_with_trailing_fields(trailers);
649631

650632
let headers = HeaderMap::from_iter(vec![(
@@ -657,4 +639,34 @@ mod tests {
657639
dst.put(buf1);
658640
assert_eq!(dst, b"0\r\nChunky-Trailer: header data\r\n\r\n");
659641
}
642+
643+
#[test]
644+
fn chunked_trailers_case_insensitive_matching() {
645+
// Regression test for issue #4010: HTTP/1.1 trailers are case-sensitive
646+
//
647+
// Previously, the Trailer header values were stored as HeaderValue (preserving case)
648+
// and compared against HeaderName (which is always lowercase). This caused trailers
649+
// declared as "Chunky-Trailer" to not match actual trailers sent as "chunky-trailer".
650+
//
651+
// The fix converts Trailer header values to HeaderName during parsing, which
652+
// normalizes the case and enables proper case-insensitive matching.
653+
//
654+
// Note: HeaderName::from_static() requires lowercase input. In real usage,
655+
// HeaderName::from_bytes() is used to parse the Trailer header value, which
656+
// normalizes mixed-case input like "Chunky-Trailer" to "chunky-trailer".
657+
let encoder = Encoder::chunked();
658+
let trailers = vec![HeaderName::from_static("chunky-trailer")];
659+
let encoder = encoder.into_chunked_with_trailing_fields(trailers);
660+
661+
// The actual trailer being sent
662+
let headers = HeaderMap::from_iter(vec![(
663+
HeaderName::from_static("chunky-trailer"),
664+
HeaderValue::from_static("trailer value"),
665+
)]);
666+
667+
let buf = encoder.encode_trailers::<&[u8]>(headers, false).unwrap();
668+
let mut dst = Vec::new();
669+
dst.put(buf);
670+
assert_eq!(dst, b"0\r\nchunky-trailer: trailer value\r\n\r\n");
671+
}
660672
}

src/proto/h1/role.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ impl Server {
650650
};
651651

652652
let mut encoder = Encoder::length(0);
653-
let mut allowed_trailer_fields: Option<Vec<HeaderValue>> = None;
653+
let mut allowed_trailer_fields: Option<Vec<HeaderName>> = None;
654654
let mut wrote_date = false;
655655
let mut cur_name = None;
656656
let mut is_name_written = false;
@@ -860,12 +860,22 @@ impl Server {
860860
extend(dst, value.as_bytes());
861861
}
862862

863-
match allowed_trailer_fields {
864-
Some(ref mut allowed_trailer_fields) => {
865-
allowed_trailer_fields.push(value);
866-
}
867-
None => {
868-
allowed_trailer_fields = Some(vec![value]);
863+
// Parse the Trailer header value into HeaderNames.
864+
// The value may contain comma-separated names.
865+
// HeaderName normalizes to lowercase for case-insensitive matching.
866+
if let Ok(value_str) = value.to_str() {
867+
let names: Vec<HeaderName> = value_str
868+
.split(',')
869+
.filter_map(|s| HeaderName::from_bytes(s.trim().as_bytes()).ok())
870+
.collect();
871+
872+
match allowed_trailer_fields {
873+
Some(ref mut fields) => {
874+
fields.extend(names);
875+
}
876+
None => {
877+
allowed_trailer_fields = Some(names);
878+
}
869879
}
870880
}
871881

@@ -1389,8 +1399,16 @@ impl Client {
13891399

13901400
let encoder = encoder.map(|enc| {
13911401
if enc.is_chunked() {
1392-
let allowed_trailer_fields: Vec<HeaderValue> =
1393-
headers.get_all(header::TRAILER).iter().cloned().collect();
1402+
// Parse Trailer header values into HeaderNames.
1403+
// Each Trailer header value may contain comma-separated names.
1404+
// HeaderName normalizes to lowercase, enabling case-insensitive matching.
1405+
let allowed_trailer_fields: Vec<HeaderName> = headers
1406+
.get_all(header::TRAILER)
1407+
.iter()
1408+
.filter_map(|hv| hv.to_str().ok())
1409+
.flat_map(|s| s.split(','))
1410+
.filter_map(|s| HeaderName::from_bytes(s.trim().as_bytes()).ok())
1411+
.collect();
13941412

13951413
if !allowed_trailer_fields.is_empty() {
13961414
return enc.into_chunked_with_trailing_fields(allowed_trailer_fields);

tests/client.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,44 @@ test! {
704704
body: None,
705705
}
706706

707+
test! {
708+
name: client_post_req_body_chunked_with_trailer_titlecase,
709+
710+
server:
711+
expected: "\
712+
POST / HTTP/1.1\r\n\
713+
trailer: Chunky-Trailer\r\n\
714+
host: {addr}\r\n\
715+
transfer-encoding: chunked\r\n\
716+
\r\n\
717+
5\r\n\
718+
hello\r\n\
719+
0\r\n\
720+
chunky-trailer: header data\r\n\
721+
\r\n\
722+
",
723+
reply: REPLY_OK,
724+
725+
client:
726+
request: {
727+
method: POST,
728+
url: "http://{addr}/",
729+
headers: {
730+
"trailer" => "Chunky-Trailer",
731+
},
732+
body_stream_with_trailers: (
733+
(futures_util::stream::once(async { Ok::<_, Infallible>(Bytes::from("hello"))})),
734+
HeaderMap::from_iter(vec![(
735+
HeaderName::from_static("chunky-trailer"),
736+
HeaderValue::from_static("header data")
737+
)].into_iter())),
738+
},
739+
response:
740+
status: OK,
741+
headers: {},
742+
body: None,
743+
}
744+
707745
test! {
708746
name: client_res_body_chunked_with_trailer,
709747

@@ -740,6 +778,42 @@ test! {
740778
},
741779
}
742780

781+
test! {
782+
name: client_res_body_chunked_with_trailer_titlecase,
783+
784+
server:
785+
expected: "GET / HTTP/1.1\r\nte: trailers\r\nhost: {addr}\r\n\r\n",
786+
reply: "\
787+
HTTP/1.1 200 OK\r\n\
788+
transfer-encoding: chunked\r\n\
789+
trailer: Chunky-Trailer\r\n\
790+
\r\n\
791+
5\r\n\
792+
hello\r\n\
793+
0\r\n\
794+
chunky-trailer: header data\r\n\
795+
\r\n\
796+
",
797+
798+
client:
799+
request: {
800+
method: GET,
801+
url: "http://{addr}/",
802+
headers: {
803+
"te" => "trailers",
804+
},
805+
},
806+
response:
807+
status: OK,
808+
headers: {
809+
"Transfer-Encoding" => "chunked",
810+
},
811+
body: &b"hello"[..],
812+
trailers: {
813+
"chunky-trailer" => "header data",
814+
},
815+
}
816+
743817
test! {
744818
name: client_res_body_chunked_with_pathological_trailers,
745819

tests/server.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2850,6 +2850,51 @@ fn http1_trailer_send_fields() {
28502850
assert_eq!(body, expected_body);
28512851
}
28522852

2853+
#[test]
2854+
fn http1_trailer_send_fields_titlecase() {
2855+
let body = futures_util::stream::once(async move { Ok("hello".into()) });
2856+
let mut headers = HeaderMap::new();
2857+
headers.insert("chunky-trailer", "header data".parse().unwrap());
2858+
// Invalid trailer field that should not be sent
2859+
headers.insert("Host", "www.example.com".parse().unwrap());
2860+
// Not specified in Trailer header, so should not be sent
2861+
headers.insert("foo", "bar".parse().unwrap());
2862+
2863+
let server = serve();
2864+
server
2865+
.reply()
2866+
.header("transfer-encoding", "chunked")
2867+
.header("trailer", "Chunky-Trailer")
2868+
.body_stream_with_trailers(body, headers);
2869+
let mut req = connect(server.addr());
2870+
req.write_all(
2871+
b"\
2872+
GET / HTTP/1.1\r\n\
2873+
Host: example.domain\r\n\
2874+
Connection: keep-alive\r\n\
2875+
TE: trailers\r\n\
2876+
\r\n\
2877+
",
2878+
)
2879+
.expect("writing");
2880+
2881+
let chunky_trailer_chunk = b"\r\nchunky-trailer: header data\r\n\r\n";
2882+
let res = read_until(&mut req, |buf| buf.ends_with(chunky_trailer_chunk)).expect("reading");
2883+
let sres = s(&res);
2884+
2885+
let expected_head =
2886+
"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\ntrailer: Chunky-Trailer\r\n";
2887+
assert_eq!(&sres[..expected_head.len()], expected_head);
2888+
2889+
// skip the date header
2890+
let date_fragment = "GMT\r\n\r\n";
2891+
let pos = sres.find(date_fragment).expect("find GMT");
2892+
let body = &sres[pos + date_fragment.len()..];
2893+
2894+
let expected_body = "5\r\nhello\r\n0\r\nchunky-trailer: header data\r\n\r\n";
2895+
assert_eq!(body, expected_body);
2896+
}
2897+
28532898
#[test]
28542899
fn http1_trailer_fields_not_allowed() {
28552900
let body = futures_util::stream::once(async move { Ok("hello".into()) });

0 commit comments

Comments
 (0)