From 5244c7fcc919050a3c79f9a1e7516b391798cb97 Mon Sep 17 00:00:00 2001 From: Jakub Florkowski Date: Sun, 1 Mar 2026 03:11:35 +0100 Subject: [PATCH] Always include Vary: Accept-Encoding for compressible responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response compression middleware previously skipped wrapping the response entirely when the request lacked an Accept-Encoding header. This meant the Vary: Accept-Encoding header was never added, which breaks HTTP caching semantics — intermediate caches wouldn't know to vary by Accept-Encoding for responses that could be compressed. Now the middleware always wraps the response body. When the MIME type is compressible, Vary: Accept-Encoding is added regardless of whether Accept-Encoding was present. Actual compression is only attempted when Accept-Encoding is present (via the allowCompression flag). Fixes #48008 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/ResponseCompressionBody.cs | 27 ++++++++++++------- .../src/ResponseCompressionMiddleware.cs | 10 +++---- .../test/ResponseCompressionBodyTest.cs | 10 +++---- .../test/ResponseCompressionMiddlewareTest.cs | 9 +++---- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionBody.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionBody.cs index 2eefdb6482b8..ef74ba89164c 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionBody.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionBody.cs @@ -26,14 +26,16 @@ internal sealed class ResponseCompressionBody : Stream, IHttpResponseBodyFeature private bool _providerCreated; private bool _autoFlush; private bool _complete; + private readonly bool _allowCompression; internal ResponseCompressionBody(HttpContext context, IResponseCompressionProvider provider, - IHttpResponseBodyFeature innerBodyFeature) + IHttpResponseBodyFeature innerBodyFeature, bool allowCompression) { _context = context; _provider = provider; _innerBodyFeature = innerBodyFeature; _innerStream = innerBodyFeature.Stream; + _allowCompression = allowCompression; } internal async Task FinishCompressionAsync() @@ -224,17 +226,22 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella headers.Vary = StringValues.Concat(headers.Vary, HeaderNames.AcceptEncoding); } - var compressionProvider = ResolveCompressionProvider(); - if (compressionProvider != null) + // Only attempt compression when the request includes Accept-Encoding. + // The Vary header is always added above for correct caching behavior. + if (_allowCompression) { - // Can't use += as StringValues does not override operator+ - // and the implict conversions will cause an incorrect string concat https://github.com/dotnet/runtime/issues/52507 - headers.ContentEncoding = StringValues.Concat(headers.ContentEncoding, compressionProvider.EncodingName); - headers.ContentMD5 = default; // Reset the MD5 because the content changed. - headers.ContentLength = default; - } + var compressionProvider = ResolveCompressionProvider(); + if (compressionProvider != null) + { + // Can't use += as StringValues does not override operator+ + // and the implict conversions will cause an incorrect string concat https://github.com/dotnet/runtime/issues/52507 + headers.ContentEncoding = StringValues.Concat(headers.ContentEncoding, compressionProvider.EncodingName); + headers.ContentMD5 = default; // Reset the MD5 because the content changed. + headers.ContentLength = default; + } - return compressionProvider; + return compressionProvider; + } } return null; diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs index 22a9677d6e10..4a67f0418fa0 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs @@ -36,21 +36,17 @@ public ResponseCompressionMiddleware(RequestDelegate next, IResponseCompressionP /// A task that represents the execution of this middleware. public Task Invoke(HttpContext context) { - if (!_provider.CheckRequestAcceptsCompression(context)) - { - return _next(context); - } - return InvokeCore(context); + return InvokeCore(context, _provider.CheckRequestAcceptsCompression(context)); } - private async Task InvokeCore(HttpContext context) + private async Task InvokeCore(HttpContext context, bool allowCompression) { var originalBodyFeature = context.Features.Get(); var originalCompressionFeature = context.Features.Get(); Debug.Assert(originalBodyFeature != null); - var compressionBody = new ResponseCompressionBody(context, _provider, originalBodyFeature); + var compressionBody = new ResponseCompressionBody(context, _provider, originalBodyFeature, allowCompression); context.Features.Set(compressionBody); context.Features.Set(compressionBody); diff --git a/src/Middleware/ResponseCompression/test/ResponseCompressionBodyTest.cs b/src/Middleware/ResponseCompression/test/ResponseCompressionBodyTest.cs index 0999ddd07b8e..711d85897e6e 100644 --- a/src/Middleware/ResponseCompression/test/ResponseCompressionBodyTest.cs +++ b/src/Middleware/ResponseCompression/test/ResponseCompressionBodyTest.cs @@ -18,7 +18,7 @@ public void OnWrite_AppendsAcceptEncodingToVaryHeader_IfNotPresent(string provid { var httpContext = new DefaultHttpContext(); httpContext.Response.Headers.Vary = providedVaryHeader; - var stream = new ResponseCompressionBody(httpContext, new MockResponseCompressionProvider(flushable: true), new StreamResponseBodyFeature(new MemoryStream())); + var stream = new ResponseCompressionBody(httpContext, new MockResponseCompressionProvider(flushable: true), new StreamResponseBodyFeature(new MemoryStream()), allowCompression: true); stream.Write(new byte[] { }, 0, 0); @@ -34,7 +34,7 @@ public void Write_IsPassedToUnderlyingStream_WhenDisableResponseBuffering(bool f var buffer = new byte[] { 1 }; var memoryStream = new MemoryStream(); - var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(flushable), new StreamResponseBodyFeature(memoryStream)); + var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(flushable), new StreamResponseBodyFeature(memoryStream), allowCompression: true); stream.DisableBuffering(); stream.Write(buffer, 0, buffer.Length); @@ -50,7 +50,7 @@ public async Task WriteAsync_IsPassedToUnderlyingStream_WhenDisableResponseBuffe var buffer = new byte[] { 1 }; var memoryStream = new MemoryStream(); - var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(flushable), new StreamResponseBodyFeature(memoryStream)); + var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(flushable), new StreamResponseBodyFeature(memoryStream), allowCompression: true); stream.DisableBuffering(); await stream.WriteAsync(buffer, 0, buffer.Length); @@ -63,7 +63,7 @@ public async Task SendFileAsync_IsPassedToUnderlyingStream_WhenDisableResponseBu { var memoryStream = new MemoryStream(); - var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(true), new StreamResponseBodyFeature(memoryStream)); + var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(true), new StreamResponseBodyFeature(memoryStream), allowCompression: true); stream.DisableBuffering(); @@ -82,7 +82,7 @@ public void BeginWrite_IsPassedToUnderlyingStream_WhenDisableResponseBuffering(b var memoryStream = new MemoryStream(); - var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(flushable), new StreamResponseBodyFeature(memoryStream)); + var stream = new ResponseCompressionBody(new DefaultHttpContext(), new MockResponseCompressionProvider(flushable), new StreamResponseBodyFeature(memoryStream), allowCompression: true); stream.DisableBuffering(); stream.BeginWrite(buffer, 0, buffer.Length, (o) => { }, null); diff --git a/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs b/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs index 4f1d1def09fc..983b8e04b99f 100644 --- a/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs +++ b/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs @@ -45,8 +45,8 @@ public async Task Request_NoAcceptEncoding_Uncompressed() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: null, responseType: TextPlain); - CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); - AssertLog(logMessages.Single(), LogLevel.Debug, "No response compression available, the Accept-Encoding header is missing or invalid."); + CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); + Assert.Contains(logMessages, lm => lm.LogLevel == LogLevel.Debug && lm.State.ToString().Contains("Accept-Encoding header is missing or invalid")); } [Fact] @@ -136,10 +136,9 @@ public async Task RequestHead_NoAcceptEncoding_Uncompressed() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: null, responseType: TextPlain, httpMethod: HttpMethods.Head); - CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); - AssertLog(logMessages.Single(), LogLevel.Debug, "No response compression available, the Accept-Encoding header is missing or invalid."); + CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); + Assert.Contains(logMessages, lm => lm.LogLevel == LogLevel.Debug && lm.State.ToString().Contains("Accept-Encoding header is missing or invalid")); } - [Fact] public async Task RequestHead_AcceptGzipDeflate_CompressedGzip() {