From 06335158ca48724db9bf074398067d2db08613e7 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Thu, 1 Jul 2021 12:36:41 -0700 Subject: [PATCH] fix(http1): reject content-lengths that have a plus sign prefix The HTTP/1 content-length parser would accept lengths that were prefixed with a plus sign (for example, `+1234`). The specification restricts the content-length header to only allow DIGITs, making such a content-length illegal. Since some HTTP implementations protect against that, and others mis-interpret the length when the plus sign is present, this fixes hyper to always reject such content lengths. See GHSA-f3pg-qwvg-p99c --- src/headers.rs | 31 +++++++++++++++++++++++++++++-- src/proto/h1/role.rs | 24 ++++++++++++++++++++---- tests/server.rs | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/headers.rs b/src/headers.rs index 0d16cdfd5a..8407be185f 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -30,7 +30,7 @@ fn connection_has(value: &HeaderValue, needle: &str) -> bool { #[cfg(all(feature = "http1", feature = "server"))] pub(super) fn content_length_parse(value: &HeaderValue) -> Option { - value.to_str().ok().and_then(|s| s.parse().ok()) + from_digits(value.as_bytes()) } pub(super) fn content_length_parse_all(headers: &HeaderMap) -> Option { @@ -46,7 +46,7 @@ pub(super) fn content_length_parse_all_values(values: ValueIter<'_, HeaderValue> for h in values { if let Ok(line) = h.to_str() { for v in line.split(',') { - if let Some(n) = v.trim().parse().ok() { + if let Some(n) = from_digits(v.trim().as_bytes()) { if content_length.is_none() { content_length = Some(n) } else if content_length != Some(n) { @@ -64,6 +64,33 @@ pub(super) fn content_length_parse_all_values(values: ValueIter<'_, HeaderValue> return content_length } +fn from_digits(bytes: &[u8]) -> Option { + // cannot use FromStr for u64, since it allows a signed prefix + let mut result = 0u64; + const RADIX: u64 = 10; + + if bytes.is_empty() { + return None; + } + + for &b in bytes { + // can't use char::to_digit, since we haven't verified these bytes + // are utf-8. + match b { + b'0'..=b'9' => { + result = result.checked_mul(RADIX)?; + result = result.checked_add((b - b'0') as u64)?; + }, + _ => { + // not a DIGIT, get outta here! + return None; + } + } + } + + Some(result) +} + #[cfg(all(feature = "http2", feature = "client"))] pub(super) fn method_has_defined_payload_semantics(method: &Method) -> bool { match *method { diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index c8174044b3..098febab3e 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -219,10 +219,8 @@ impl Http1Transaction for Server { if is_te { continue; } - let len = value - .to_str() - .map_err(|_| Parse::content_length_invalid()) - .and_then(|s| s.parse().map_err(|_| Parse::content_length_invalid()))?; + let len = headers::content_length_parse(&value) + .ok_or_else(Parse::content_length_invalid)?; if let Some(prev) = con_len { if prev != len { debug!( @@ -1775,6 +1773,16 @@ mod tests { "multiple content-lengths", ); + // content-length with prefix is not allowed + parse_err( + "\ + POST / HTTP/1.1\r\n\ + content-length: +10\r\n\ + \r\n\ + ", + "prefixed content-length", + ); + // transfer-encoding that isn't chunked is an error parse_err( "\ @@ -1958,6 +1966,14 @@ mod tests { ", ); + parse_err( + "\ + HTTP/1.1 200 OK\r\n\ + content-length: +8\r\n\ + \r\n\ + ", + ); + // transfer-encoding: chunked assert_eq!( parse( diff --git a/tests/server.rs b/tests/server.rs index 5f6a7742b4..7e9d4d5815 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -405,6 +405,44 @@ fn get_chunked_response_with_ka() { read_until(&mut req, |buf| buf.ends_with(quux)).expect("reading 2"); } +#[test] +fn post_with_content_length_body() { + let server = serve(); + let mut req = connect(server.addr()); + req.write_all( + b"\ + POST / HTTP/1.1\r\n\ + Content-Length: 5\r\n\ + \r\n\ + hello\ + ", + ) + .unwrap(); + req.read(&mut [0; 256]).unwrap(); + + assert_eq!(server.body(), b"hello"); +} + +#[test] +fn post_with_invalid_prefix_content_length() { + let server = serve(); + let mut req = connect(server.addr()); + req.write_all( + b"\ + POST / HTTP/1.1\r\n\ + Content-Length: +5\r\n\ + \r\n\ + hello\ + ", + ) + .unwrap(); + + let mut buf = [0; 256]; + let _n = req.read(&mut buf).unwrap(); + let expected = "HTTP/1.1 400 Bad Request\r\n"; + assert_eq!(s(&buf[..expected.len()]), expected); +} + #[test] fn post_with_chunked_body() { let server = serve();