fix: avoid sending HEAD bodies over TLS HTTP/2 (#2648)

Signed-off-by: 唐小鸭 <tangtang1251@qq.com>
Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
This commit is contained in:
唐小鸭
2026-04-25 09:33:08 +08:00
committed by GitHub
parent 7215761784
commit d949d4e794
10 changed files with 701 additions and 2 deletions
+7 -2
View File
@@ -22,8 +22,8 @@ use crate::server::{
compress::{CompressionConfig, PathAwareCompressionPredicate, PathCategoryInjectionLayer},
hybrid::hybrid,
layer::{
AdminChunkedContentLengthCompatLayer, BodylessStatusFixLayer, ConditionalCorsLayer, ObjectAttributesEtagFixLayer,
RedirectLayer, RequestContextLayer, S3ErrorMessageCompatLayer,
AdminChunkedContentLengthCompatLayer, BodylessStatusFixLayer, ConditionalCorsLayer, HeadRequestBodyFixLayer,
ObjectAttributesEtagFixLayer, RedirectLayer, RequestContextLayer, S3ErrorMessageCompatLayer,
},
tls_material::{TlsAcceptorHolder, TlsHandshakeFailureKind, TlsMaterialSnapshot, spawn_reload_loop},
};
@@ -656,6 +656,7 @@ fn process_connection(
// 16. ConditionalCorsLayer — S3 API CORS
// 17. RedirectLayer — console redirect (conditional)
// 18. BodylessStatusFixLayer — clears body for 1xx/204/205/304 responses
// 19. HeadRequestBodyFixLayer — strips actual body bytes from HEAD responses
// ─────────────────────────────────────────────────────────────
let hybrid_service = ServiceBuilder::new()
// NOTE: Both extension types are intentionally inserted to maintain compatibility:
@@ -807,6 +808,10 @@ fn process_connection(
// other response-transforming layers see the already-bodyless
// response and so no layer (e.g. CORS) re-adds body headers afterward.
.layer(BodylessStatusFixLayer)
// HEAD responses must not send body bytes even when the inner S3 layer
// serializes an XML error payload. Keep this innermost so the final
// HTTP response written to hyper/h2 is bodyless.
.layer(HeadRequestBodyFixLayer)
.service(service);
let hybrid_service = TowerToHyperService::new(hybrid_service);
+172
View File
@@ -477,6 +477,74 @@ where
}
}
/// Tower middleware that strips the actual response body for `HEAD` requests
/// while preserving metadata headers such as `Content-Length`.
///
/// The inner s3s layer may serialize S3 errors as XML bodies. That is valid for
/// regular requests, but for `HEAD` the HTTP layer must suppress the response
/// body entirely. If we forward the serialized error body over HTTP/2, clients
/// observe DATA frames on a `HEAD` response and fail the exchange with a
/// protocol error.
#[derive(Clone)]
pub struct HeadRequestBodyFixLayer;
impl<S> Layer<S> for HeadRequestBodyFixLayer {
type Service = HeadRequestBodyFixService<S>;
fn layer(&self, inner: S) -> Self::Service {
HeadRequestBodyFixService { inner }
}
}
#[derive(Clone)]
pub struct HeadRequestBodyFixService<S> {
inner: S,
}
impl<S, ReqBody, RestBody, GrpcBody> Service<HttpRequest<ReqBody>> for HeadRequestBodyFixService<S>
where
S: Service<HttpRequest<ReqBody>, Response = Response<HybridBody<RestBody, GrpcBody>>> + Clone + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
RestBody: Body<Data = Bytes> + From<Bytes> + Send + 'static,
GrpcBody: Send + 'static,
{
type Response = Response<HybridBody<RestBody, GrpcBody>>;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: HttpRequest<ReqBody>) -> Self::Future {
let is_head = req.method() == Method::HEAD;
let mut inner = self.inner.clone();
Box::pin(async move {
let response = inner.call(req).await?;
if !is_head {
return Ok(response);
}
let (mut parts, body) = response.into_parts();
parts.headers.remove(http::header::TRANSFER_ENCODING);
let response = match body {
HybridBody::Rest { .. } => Response::from_parts(
parts,
HybridBody::Rest {
rest_body: RestBody::from(Bytes::new()),
},
),
HybridBody::Grpc { grpc_body } => Response::from_parts(parts, HybridBody::Grpc { grpc_body }),
};
Ok(response)
})
}
}
fn is_bodyless_status(status: StatusCode) -> bool {
status.is_informational()
|| status == StatusCode::NO_CONTENT
@@ -1269,6 +1337,110 @@ mod tests {
}
}
mod head_request_body_fix {
use super::*;
use crate::server::hybrid::HybridBody;
use http_body_util::Empty;
#[derive(Clone)]
struct FixedResponse {
status: StatusCode,
body: Bytes,
content_type: Option<&'static str>,
}
impl<B: Send + 'static> Service<Request<B>> for FixedResponse {
type Response = Response<HybridBody<Full<Bytes>, Empty<Bytes>>>;
type Error = Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: Request<B>) -> Self::Future {
let this = self.clone();
Box::pin(async move {
let body = this.body.clone();
let len = body.len();
let mut builder = Response::builder().status(this.status);
builder = builder.header(http::header::CONTENT_LENGTH, len.to_string());
builder = builder.header(http::header::TRANSFER_ENCODING, "chunked");
if let Some(ct) = this.content_type {
builder = builder.header(http::header::CONTENT_TYPE, ct);
}
Ok(builder
.body(HybridBody::Rest {
rest_body: Full::from(body),
})
.expect("build response"))
})
}
}
fn request_with_method(method: Method) -> Request<()> {
Request::builder()
.method(method)
.uri("/bucket/object")
.body(())
.expect("request")
}
async fn collect_body<B: Body<Data = Bytes>>(body: B) -> Bytes
where
B::Error: std::fmt::Debug,
{
BodyExt::collect(body).await.expect("collect body").to_bytes()
}
#[tokio::test]
async fn strips_body_for_head_errors_but_preserves_metadata_headers() {
let payload = Bytes::from_static(b"<?xml version=\"1.0\"?><Error><Code>NoSuchKey</Code></Error>");
let mut svc = HeadRequestBodyFixLayer.layer(FixedResponse {
status: StatusCode::NOT_FOUND,
body: payload.clone(),
content_type: Some("application/xml"),
});
let res = svc.call(request_with_method(Method::HEAD)).await.expect("service call");
let (parts, body) = res.into_parts();
assert_eq!(parts.status, StatusCode::NOT_FOUND);
assert_eq!(
parts.headers.get(http::header::CONTENT_LENGTH).unwrap(),
payload.len().to_string().as_str()
);
assert_eq!(parts.headers.get(http::header::CONTENT_TYPE).unwrap(), "application/xml");
assert!(parts.headers.get(http::header::TRANSFER_ENCODING).is_none());
let bytes = collect_body(body).await;
assert!(bytes.is_empty(), "HEAD response body must be empty");
}
#[tokio::test]
async fn preserves_body_for_get_errors() {
let payload = Bytes::from_static(b"<?xml version=\"1.0\"?><Error><Code>NoSuchKey</Code></Error>");
let mut svc = HeadRequestBodyFixLayer.layer(FixedResponse {
status: StatusCode::NOT_FOUND,
body: payload.clone(),
content_type: Some("application/xml"),
});
let res = svc.call(request_with_method(Method::GET)).await.expect("service call");
let (parts, body) = res.into_parts();
assert_eq!(parts.status, StatusCode::NOT_FOUND);
assert_eq!(
parts.headers.get(http::header::CONTENT_LENGTH).unwrap(),
payload.len().to_string().as_str()
);
assert_eq!(parts.headers.get(http::header::TRANSFER_ENCODING).unwrap(), "chunked");
let bytes = collect_body(body).await;
assert_eq!(bytes, payload);
}
}
#[test]
fn test_apply_bucket_cors_result_replaces_existing_cors_headers() {
let mut response_headers = HeaderMap::new();