From 99e2714b293c160b64d3fc4c6fc6781b193c6897 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 13 Jul 2026 08:43:44 -0400 Subject: [PATCH] fix(compression): reject non-empty data frames after codec is EOF This is a regression introduced in #685. While trailers frames should be forwarded, they already were. Extra data frames after decompression is complete is a body error. --- tower-http/src/compression_utils.rs | 12 ++++++- tower-http/src/decompression/mod.rs | 50 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/tower-http/src/compression_utils.rs b/tower-http/src/compression_utils.rs index 9c5cdc205..1fbccb856 100644 --- a/tower-http/src/compression_utils.rs +++ b/tower-http/src/compression_utils.rs @@ -238,9 +238,19 @@ where // poll any remaining frames, such as trailers let body = M::get_pin_mut(this.read).get_pin_mut().get_pin_mut(); match ready!(body.poll_frame(cx)) { - Some(Ok(frame)) => Poll::Ready(Some(Ok( + Some(Ok(frame)) if frame.is_trailers() => Poll::Ready(Some(Ok( frame.map_data(|mut data| data.copy_to_bytes(data.remaining())) ))), + Some(Ok(frame)) => { + if let Ok(bytes) = frame.into_data() { + if bytes.has_remaining() { + return Poll::Ready(Some(Err( + "there are extra bytes after body has been decompressed".into(), + ))); + } + } + Poll::Ready(None) + } Some(Err(err)) => Poll::Ready(Some(Err(err.into()))), None => Poll::Ready(None), } diff --git a/tower-http/src/decompression/mod.rs b/tower-http/src/decompression/mod.rs index dfdbaf420..3fb2c0c37 100644 --- a/tower-http/src/decompression/mod.rs +++ b/tower-http/src/decompression/mod.rs @@ -111,10 +111,13 @@ pub use self::request::service::RequestDecompression; mod tests { use std::convert::Infallible; use std::io::Write; + use std::time::Duration; use super::*; use crate::test_helpers::Body; use crate::{compression::Compression, test_helpers::WithTrailers}; + use bytes::Bytes; + use futures_util::StreamExt; use http::Response; use http::{HeaderMap, HeaderName, Request}; use http_body_util::BodyExt; @@ -251,4 +254,51 @@ mod tests { .body(body) .unwrap()) } + + #[cfg(feature = "decompression-br")] + #[tokio::test] + async fn brotli_rejects_extra_data_without_waiting_for_end_of_body() { + let mut compressed = Vec::new(); + { + let mut encoder = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 20); + encoder.write_all(b"Hello, World!").unwrap(); + } + + let svc = service_fn(move |_req: Request| { + let compressed = compressed.clone(); + async move { + let stream = futures_util::stream::iter([ + Ok::<_, Infallible>(Bytes::from(compressed)), + Ok(Bytes::from_static(b"extra")), + ]) + .chain(futures_util::stream::pending()); + + Ok::<_, Infallible>( + Response::builder() + .header("content-encoding", "br") + .body(Body::from_stream(stream)) + .unwrap(), + ) + } + }); + let mut client = Decompression::new(svc); + + let res = client + .ready() + .await + .unwrap() + .call(Request::new(Body::empty())) + .await + .unwrap(); + + let result = tokio::time::timeout(Duration::from_secs(1), res.into_body().collect()) + .await + .expect("extra data should produce an error without waiting for the body to end"); + let error = result.unwrap_err(); + + assert_eq!( + error.to_string(), + "there are extra bytes after body has been decompressed" + ); + } }