fix(http): preserve manual Content-Length headers for HEAD requests per RFC 7230

Implements RFC 7230 Section 3.3.2 which allows servers to send Content-Length
headers in HEAD responses. This enables support for APIs like AWS S3 HeadObject
that require Content-Length headers even with empty response bodies.

Previously, manually set Content-Length headers on empty body responses were
being overridden by automatic "content-length: 0" headers. Now the encoder
checks for existing Content-Length headers before adding automatic ones.
This commit is contained in:
Tao Guo 2025-08-09 11:24:45 -07:00
parent 351f7a17ed
commit 7dc772c96d
1 changed files with 54 additions and 1 deletions

View File

@ -60,8 +60,13 @@ pub(crate) trait MessageType: Sized {
config: &ServiceConfig,
) -> io::Result<()> {
let chunked = self.chunked();
let mut skip_len = length != BodySize::Stream;
let mut skip_len = false;
let camel_case = self.camel_case();
// Check if Content-Length header is already set
let has_content_length = self.headers().contains_key(CONTENT_LENGTH)
|| self
.extra_headers()
.is_some_and(|h| h.contains_key(CONTENT_LENGTH));
// Content length
if let Some(status) = self.status() {
@ -103,6 +108,7 @@ pub(crate) trait MessageType: Sized {
dst.put_slice(b"\r\n");
}
}
BodySize::Sized(_len) if skip_len || has_content_length => dst.put_slice(b"\r\n"),
BodySize::Sized(0) if camel_case => dst.put_slice(b"\r\nContent-Length: 0\r\n"),
BodySize::Sized(0) => dst.put_slice(b"\r\ncontent-length: 0\r\n"),
BodySize::Sized(len) => helpers::write_content_length(len, dst, camel_case),
@ -668,4 +674,51 @@ mod tests {
assert!(!data.contains("content-length: 0\r\n"));
assert!(!data.contains("transfer-encoding: chunked\r\n"));
}
#[actix_rt::test]
async fn test_manual_content_length_preserved() {
let mut bytes = BytesMut::with_capacity(2048);
// Test with OK response and manual Content-Length header
let mut res = Response::with_body(StatusCode::OK, ());
res.headers_mut()
.insert(CONTENT_LENGTH, HeaderValue::from_static("456"));
let _ = res.encode_headers(
&mut bytes,
Version::HTTP_11,
BodySize::Sized(0), // Empty body
ConnectionType::KeepAlive,
&ServiceConfig::default(),
);
let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap();
// Should only have the manual Content-Length: 456, not an automatic content-length: 0
assert!(data.contains("content-length: 456\r\n"));
assert!(!data.contains("content-length: 0\r\n"));
// Count occurrences of "content-length" - should be exactly 1
let count = data.matches("content-length").count();
assert_eq!(count, 1, "Should have exactly one content-length header");
}
#[actix_rt::test]
async fn test_automatic_content_length_for_empty_body() {
let mut bytes = BytesMut::with_capacity(2048);
// Test without manual Content-Length header
let mut res = Response::with_body(StatusCode::OK, ());
let _ = res.encode_headers(
&mut bytes,
Version::HTTP_11,
BodySize::Sized(0), // Empty body
ConnectionType::KeepAlive,
&ServiceConfig::default(),
);
let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap();
// Should have automatic content-length: 0
assert!(data.contains("content-length: 0\r\n"));
}
}