mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-08 22:33:22 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user