Allow http/1.0 responses to have content-length (#625)

Previously, we treated HTTP/1.0 responses as always being
close-delimited. However, that's not quite right. HTTP/1.0 responses
_default_ to Connection: close, but the server may send Connection:
keep-alive, along with a Content-Length header. Update body_type to
understand this.

Also, slightly reorganize body_type. has_no_body was being checked
redundantly when we could just early-return when has_no_body is true. And
regardless of whether we see Connection: close (on any HTTP version),
we must honor Transfer-Encoding or Content-Length if they are present.

Change the handling of `Connection: close` to more directly affect
whether a connection is pooled, regardless of what method was used
to delimit the body.

Fixes #600
This commit is contained in:
Jacob Hoffman-Andrews
2023-06-14 16:20:00 -07:00
committed by GitHub
parent 905bf8f0b2
commit a8c038529f
2 changed files with 193 additions and 21 deletions

View File

@@ -36,7 +36,13 @@ const INTO_STRING_LIMIT: usize = 10 * 1_024 * 1_024;
const MAX_HEADER_SIZE: usize = 100 * 1_024; const MAX_HEADER_SIZE: usize = 100 * 1_024;
const MAX_HEADER_COUNT: usize = 100; const MAX_HEADER_COUNT: usize = 100;
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug, PartialEq)]
enum ConnectionOption {
KeepAlive,
Close,
}
#[derive(Copy, Clone, Debug, PartialEq)]
enum BodyType { enum BodyType {
LengthDelimited(usize), LengthDelimited(usize),
Chunked, Chunked,
@@ -279,6 +285,31 @@ impl Response {
self.reader self.reader
} }
// Determine what to do with the connection after we've read the body.
fn connection_option(
response_version: &str,
connection_header: Option<&str>,
) -> ConnectionOption {
// https://datatracker.ietf.org/doc/html/rfc9112#name-tear-down
// "A client that receives a "close" connection option MUST cease sending requests on that
// connection and close the connection after reading the response message containing the "close"
// connection option"
//
// Per https://www.rfc-editor.org/rfc/rfc2068#section-19.7.1, an HTTP/1.0 response can explicitly
// say "Connection: keep-alive" in response to a request with "Connection: keep-alive". We don't
// send "Connection: keep-alive" in the request but are willing to accept in the response anyhow.
use ConnectionOption::*;
let is_http10 = response_version.eq_ignore_ascii_case("HTTP/1.0");
match (is_http10, connection_header) {
(true, Some(c)) if c.eq_ignore_ascii_case("keep-alive") => KeepAlive,
(true, _) => Close,
(false, Some(c)) if c.eq_ignore_ascii_case("close") => Close,
(false, _) => KeepAlive,
}
}
/// Determine how the body should be read, based on
/// <https://datatracker.ietf.org/doc/html/rfc9112#name-message-body-length>
fn body_type( fn body_type(
request_method: &str, request_method: &str,
response_status: u16, response_status: u16,
@@ -286,9 +317,6 @@ impl Response {
headers: &[Header], headers: &[Header],
) -> BodyType { ) -> BodyType {
let is_http10 = response_version.eq_ignore_ascii_case("HTTP/1.0"); let is_http10 = response_version.eq_ignore_ascii_case("HTTP/1.0");
let is_close = get_header(headers, "connection")
.map(|c| c.eq_ignore_ascii_case("close"))
.unwrap_or(false);
let is_head = request_method.eq_ignore_ascii_case("head"); let is_head = request_method.eq_ignore_ascii_case("head");
let has_no_body = is_head let has_no_body = is_head
@@ -297,41 +325,41 @@ impl Response {
_ => false, _ => false,
}; };
if has_no_body {
return BodyType::LengthDelimited(0);
}
let is_chunked = get_header(headers, "transfer-encoding") let is_chunked = get_header(headers, "transfer-encoding")
.map(|enc| !enc.is_empty()) // whatever it says, do chunked .map(|enc| !enc.is_empty()) // whatever it says, do chunked
.unwrap_or(false); .unwrap_or(false);
let use_chunked = !is_http10 && !has_no_body && is_chunked; // https://www.rfc-editor.org/rfc/rfc2068#page-161
// > a persistent connection with an HTTP/1.0 client cannot make
// > use of the chunked transfer-coding
let use_chunked = !is_http10 && is_chunked;
if use_chunked { if use_chunked {
return BodyType::Chunked; return BodyType::Chunked;
} }
if has_no_body {
return BodyType::LengthDelimited(0);
}
let length = get_header(headers, "content-length").and_then(|v| v.parse::<usize>().ok()); let length = get_header(headers, "content-length").and_then(|v| v.parse::<usize>().ok());
if is_http10 || is_close { match length {
BodyType::CloseDelimited Some(n) => BodyType::LengthDelimited(n),
} else if has_no_body { None => BodyType::CloseDelimited,
// head requests never have a body
BodyType::LengthDelimited(0)
} else {
match length {
Some(n) => BodyType::LengthDelimited(n),
None => BodyType::CloseDelimited,
}
} }
} }
fn stream_to_reader( fn stream_to_reader(
stream: DeadlineStream, mut stream: DeadlineStream,
unit: &Unit, unit: &Unit,
body_type: BodyType, body_type: BodyType,
compression: Option<Compression>, compression: Option<Compression>,
connection_option: ConnectionOption,
) -> Box<dyn Read + Send + Sync + 'static> { ) -> Box<dyn Read + Send + Sync + 'static> {
if connection_option == ConnectionOption::Close {
stream.inner_mut().set_unpoolable();
}
let inner = stream.inner_ref(); let inner = stream.inner_ref();
let result = inner.set_read_timeout(unit.agent.config.timeout_read); let result = inner.set_read_timeout(unit.agent.config.timeout_read);
if let Err(e) = result { if let Err(e) = result {
@@ -575,6 +603,9 @@ impl Response {
let compression = let compression =
get_header(&headers, "content-encoding").and_then(Compression::from_header_value); get_header(&headers, "content-encoding").and_then(Compression::from_header_value);
let connection_option =
Self::connection_option(http_version, get_header(&headers, "connection"));
let body_type = Self::body_type(&unit.method, status, http_version, &headers); let body_type = Self::body_type(&unit.method, status, http_version, &headers);
// remove Content-Encoding and length due to automatic decompression // remove Content-Encoding and length due to automatic decompression
@@ -582,7 +613,8 @@ impl Response {
headers.retain(|h| !h.is_name("content-encoding") && !h.is_name("content-length")); headers.retain(|h| !h.is_name("content-encoding") && !h.is_name("content-length"));
} }
let reader = Self::stream_to_reader(stream, &unit, body_type, compression); let reader =
Self::stream_to_reader(stream, &unit, body_type, compression, connection_option);
let url = unit.url.clone(); let url = unit.url.clone();
@@ -1224,4 +1256,136 @@ mod tests {
let body = resp.into_string().unwrap(); let body = resp.into_string().unwrap();
assert_eq!(body, "hi\n"); assert_eq!(body, "hi\n");
} }
#[test]
fn connection_option() {
use ConnectionOption::*;
assert_eq!(Response::connection_option("HTTP/1.0", None), Close);
assert_eq!(Response::connection_option("HtTp/1.0", None), Close);
assert_eq!(Response::connection_option("HTTP/1.0", Some("blah")), Close);
assert_eq!(
Response::connection_option("HTTP/1.0", Some("keep-ALIVE")),
KeepAlive
);
assert_eq!(
Response::connection_option("http/1.0", Some("keep-alive")),
KeepAlive
);
assert_eq!(Response::connection_option("http/1.1", None), KeepAlive);
assert_eq!(
Response::connection_option("http/1.1", Some("blah")),
KeepAlive
);
assert_eq!(
Response::connection_option("http/1.1", Some("keep-alive")),
KeepAlive
);
assert_eq!(
Response::connection_option("http/1.1", Some("CLOSE")),
Close
);
}
#[test]
fn body_type() {
use BodyType::*;
assert_eq!(
Response::body_type("GET", 200, "HTTP/1.1", &[]),
CloseDelimited
);
assert_eq!(
Response::body_type("HEAD", 200, "HTTP/1.1", &[]),
LengthDelimited(0)
);
assert_eq!(
Response::body_type("hEaD", 200, "HTTP/1.1", &[]),
LengthDelimited(0)
);
assert_eq!(
Response::body_type("head", 200, "HTTP/1.1", &[]),
LengthDelimited(0)
);
assert_eq!(
Response::body_type("GET", 304, "HTTP/1.1", &[]),
LengthDelimited(0)
);
assert_eq!(
Response::body_type("GET", 204, "HTTP/1.1", &[]),
LengthDelimited(0)
);
assert_eq!(
Response::body_type(
"GET",
200,
"HTTP/1.1",
&[Header::new("Transfer-Encoding", "chunked"),]
),
Chunked
);
assert_eq!(
Response::body_type(
"GET",
200,
"HTTP/1.1",
&[Header::new("Content-Length", "123"),]
),
LengthDelimited(123)
);
assert_eq!(
Response::body_type(
"GET",
200,
"HTTP/1.1",
&[
Header::new("Content-Length", "123"),
Header::new("Transfer-Encoding", "chunked"),
]
),
Chunked
);
assert_eq!(
Response::body_type(
"GET",
200,
"HTTP/1.1",
&[
Header::new("Transfer-Encoding", "chunked"),
Header::new("Content-Length", "123"),
]
),
Chunked
);
assert_eq!(
Response::body_type(
"HEAD",
200,
"HTTP/1.1",
&[
Header::new("Transfer-Encoding", "chunked"),
Header::new("Content-Length", "123"),
]
),
LengthDelimited(0)
);
assert_eq!(
Response::body_type(
"GET",
200,
"HTTP/1.0",
&[Header::new("Transfer-Encoding", "chunked"),]
),
CloseDelimited,
"HTTP/1.0 did not support chunked encoding"
);
assert_eq!(
Response::body_type(
"GET",
200,
"HTTP/1.0",
&[Header::new("Content-Length", "123"),]
),
LengthDelimited(123)
);
}
} }

View File

@@ -66,6 +66,10 @@ impl DeadlineStream {
pub(crate) fn inner_ref(&self) -> &Stream { pub(crate) fn inner_ref(&self) -> &Stream {
&self.stream &self.stream
} }
pub(crate) fn inner_mut(&mut self) -> &mut Stream {
&mut self.stream
}
} }
impl From<DeadlineStream> for Stream { impl From<DeadlineStream> for Stream {
@@ -240,6 +244,10 @@ impl Stream {
} }
} }
pub(crate) fn set_unpoolable(&mut self) {
self.pool_returner = PoolReturner::none();
}
pub(crate) fn return_to_pool(mut self) -> io::Result<()> { pub(crate) fn return_to_pool(mut self) -> io::Result<()> {
// ensure stream can be reused // ensure stream can be reused
self.reset()?; self.reset()?;