diff --git a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs index 8cfeb642560b..4c2ccde3ccc2 100644 --- a/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs +++ b/src/Http/Http.Features/ref/Microsoft.AspNetCore.Http.Features.netstandard2.0.cs @@ -131,6 +131,12 @@ public partial struct FeatureReference public T Fetch(Microsoft.AspNetCore.Http.Features.IFeatureCollection features) { throw null; } public T Update(Microsoft.AspNetCore.Http.Features.IFeatureCollection features, T feature) { throw null; } } + public enum HttpsCompressionMode + { + Compress = 2, + Default = 0, + DoNotCompress = 1, + } public partial interface IFeatureCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable { bool IsReadOnly { get; } @@ -207,6 +213,10 @@ public partial interface IHttpResponseTrailersFeature { Microsoft.AspNetCore.Http.IHeaderDictionary Trailers { get; set; } } + public partial interface IHttpsCompressionFeature + { + Microsoft.AspNetCore.Http.Features.HttpsCompressionMode Mode { get; set; } + } public partial interface IHttpSendFileFeature { System.Threading.Tasks.Task SendFileAsync(string path, long offset, long? count, System.Threading.CancellationToken cancellation); diff --git a/src/Http/Http.Features/src/HttpsCompressionMode.cs b/src/Http/Http.Features/src/HttpsCompressionMode.cs new file mode 100644 index 000000000000..0ed2bcc2d686 --- /dev/null +++ b/src/Http/Http.Features/src/HttpsCompressionMode.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Features +{ + /// + /// Use to dynamically control response compression for HTTPS requests. + /// + public enum HttpsCompressionMode + { + /// + /// No value has been specified, use the configured defaults. + /// + Default = 0, + + /// + /// Opts out of compression over HTTPS. Enabling compression on HTTPS requests for remotely manipulable content + /// may expose security problems. + /// + DoNotCompress, + + /// + /// Opts into compression over HTTPS. Enabling compression on HTTPS requests for remotely manipulable content + /// may expose security problems. + /// + Compress, + } +} diff --git a/src/Http/Http.Features/src/IHttpsCompressionFeature.cs b/src/Http/Http.Features/src/IHttpsCompressionFeature.cs new file mode 100644 index 000000000000..04b05bfaf584 --- /dev/null +++ b/src/Http/Http.Features/src/IHttpsCompressionFeature.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Features +{ + /// + /// Configures response compression behavior for HTTPS on a per-request basis. + /// + public interface IHttpsCompressionFeature + { + /// + /// The to use. + /// + HttpsCompressionMode Mode { get; set; } + } +} diff --git a/src/Middleware/ResponseCompression/src/BodyWrapperStream.cs b/src/Middleware/ResponseCompression/src/BodyWrapperStream.cs index 2b756bf04151..0a89bd83d0f3 100644 --- a/src/Middleware/ResponseCompression/src/BodyWrapperStream.cs +++ b/src/Middleware/ResponseCompression/src/BodyWrapperStream.cs @@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.ResponseCompression /// /// Stream wrapper that create specific compression stream only if necessary. /// - internal class BodyWrapperStream : Stream, IHttpBufferingFeature, IHttpSendFileFeature, IHttpResponseStartFeature + internal class BodyWrapperStream : Stream, IHttpBufferingFeature, IHttpSendFileFeature, IHttpResponseStartFeature, IHttpsCompressionFeature { private readonly HttpContext _context; private readonly Stream _bodyOriginalStream; @@ -46,6 +46,8 @@ internal ValueTask FinishCompressionAsync() return _compressionStream?.DisposeAsync() ?? new ValueTask(); } + HttpsCompressionMode IHttpsCompressionFeature.Mode { get; set; } = HttpsCompressionMode.Default; + public override bool CanRead => false; public override bool CanSeek => false; diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs index 5c8b6447545a..c6962ef91976 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionMiddleware.cs @@ -55,11 +55,13 @@ public async Task Invoke(HttpContext context) var originalBufferFeature = context.Features.Get(); var originalSendFileFeature = context.Features.Get(); var originalStartFeature = context.Features.Get(); + var originalCompressionFeature = context.Features.Get(); var bodyWrapperStream = new BodyWrapperStream(context, bodyStream, _provider, originalBufferFeature, originalSendFileFeature, originalStartFeature); context.Response.Body = bodyWrapperStream; context.Features.Set(bodyWrapperStream); + context.Features.Set(bodyWrapperStream); if (originalSendFileFeature != null) { context.Features.Set(bodyWrapperStream); @@ -79,6 +81,7 @@ public async Task Invoke(HttpContext context) { context.Response.Body = bodyStream; context.Features.Set(originalBufferFeature); + context.Features.Set(originalCompressionFeature); if (originalSendFileFeature != null) { context.Features.Set(originalSendFileFeature); diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs index 45168d04d50d..f8d768d80735 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs @@ -1,7 +1,8 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using Microsoft.AspNetCore.Http.Features; namespace Microsoft.AspNetCore.ResponseCompression { @@ -22,8 +23,11 @@ public class ResponseCompressionOptions /// /// Indicates if responses over HTTPS connections should be compressed. The default is 'false'. - /// Enabling compression on HTTPS connections may expose security problems. + /// Enabling compression on HTTPS requests for remotely manipulable content may expose security problems. /// + /// + /// This can be overridden per request using . + /// public bool EnableForHttps { get; set; } = false; /// diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs index 8b2c2222e932..a0e00b077a6b 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Linq; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.ResponseCompression.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -170,6 +171,17 @@ public virtual ICompressionProvider GetCompressionProvider(HttpContext context) /// public virtual bool ShouldCompressResponse(HttpContext context) { + var httpsMode = context.Features.Get()?.Mode ?? HttpsCompressionMode.Default; + + // Check if the app has opted into or out of compression over HTTPS + if (context.Request.IsHttps + && (httpsMode == HttpsCompressionMode.DoNotCompress + || !(_enableForHttps || httpsMode == HttpsCompressionMode.Compress))) + { + _logger.NoCompressionForHttps(); + return false; + } + if (context.Response.Headers.ContainsKey(HeaderNames.ContentRange)) { _logger.NoCompressionDueToHeader(HeaderNames.ContentRange); @@ -215,12 +227,6 @@ public virtual bool ShouldCompressResponse(HttpContext context) /// public bool CheckRequestAcceptsCompression(HttpContext context) { - if (context.Request.IsHttps && !_enableForHttps) - { - _logger.NoCompressionForHttps(); - return false; - } - if (string.IsNullOrEmpty(context.Request.Headers[HeaderNames.AcceptEncoding])) { _logger.NoAcceptEncoding(); diff --git a/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs b/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs index aef87c545055..9255adcf2985 100644 --- a/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs +++ b/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs @@ -453,7 +453,123 @@ public async Task Request_Https_CompressedIfEnabled(bool enableHttps, int expect } else { - AssertLog(logMessages.Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); + AssertLog(logMessages.Skip(1).Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); + } + } + + [Theory] + [InlineData(HttpsCompressionMode.Default, 100)] + [InlineData(HttpsCompressionMode.DoNotCompress, 100)] + [InlineData(HttpsCompressionMode.Compress, 30)] + public async Task Request_Https_CompressedIfOptIn(HttpsCompressionMode mode, int expectedLength) + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(loggerFactory); + services.AddResponseCompression(options => + { + options.EnableForHttps = false; + options.MimeTypes = new[] { TextPlain }; + }); + }) + .Configure(app => + { + app.UseResponseCompression(); + app.Run(context => + { + var feature = context.Features.Get(); + feature.Mode = mode; + context.Response.ContentType = TextPlain; + return context.Response.WriteAsync(new string('a', 100)); + }); + }); + + var server = new TestServer(builder) + { + BaseAddress = new Uri("https://localhost/") + }; + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.AcceptEncoding.ParseAdd("gzip"); + + var response = await client.SendAsync(request); + + Assert.Equal(expectedLength, response.Content.ReadAsByteArrayAsync().Result.Length); + + var logMessages = sink.Writes.ToList(); + if (mode == HttpsCompressionMode.Compress) + { + AssertCompressedWithLog(logMessages, "gzip"); + } + else + { + AssertLog(logMessages.Skip(1).Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); + } + } + + [Theory] + [InlineData(HttpsCompressionMode.Default, 30)] + [InlineData(HttpsCompressionMode.Compress, 30)] + [InlineData(HttpsCompressionMode.DoNotCompress, 100)] + public async Task Request_Https_NotCompressedIfOptOut(HttpsCompressionMode mode, int expectedLength) + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(loggerFactory); + services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.MimeTypes = new[] { TextPlain }; + }); + }) + .Configure(app => + { + app.UseResponseCompression(); + app.Run(context => + { + var feature = context.Features.Get(); + feature.Mode = mode; + context.Response.ContentType = TextPlain; + return context.Response.WriteAsync(new string('a', 100)); + }); + }); + + var server = new TestServer(builder) + { + BaseAddress = new Uri("https://localhost/") + }; + + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.AcceptEncoding.ParseAdd("gzip"); + + var response = await client.SendAsync(request); + + Assert.Equal(expectedLength, response.Content.ReadAsByteArrayAsync().Result.Length); + + var logMessages = sink.Writes.ToList(); + if (mode == HttpsCompressionMode.DoNotCompress) + { + AssertLog(logMessages.Skip(1).Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); + } + else + { + AssertCompressedWithLog(logMessages, "gzip"); } } diff --git a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs index 06fc6c792cd5..76cc1985ba84 100644 --- a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs +++ b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs @@ -55,6 +55,7 @@ public StaticFileOptions() : base (default(Microsoft.AspNetCore.StaticFiles.Infr public StaticFileOptions(Microsoft.AspNetCore.StaticFiles.Infrastructure.SharedOptions sharedOptions) : base (default(Microsoft.AspNetCore.StaticFiles.Infrastructure.SharedOptions)) { } public Microsoft.AspNetCore.StaticFiles.IContentTypeProvider ContentTypeProvider { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public string DefaultContentType { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public Microsoft.AspNetCore.Http.Features.HttpsCompressionMode HttpsCompression { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public System.Action OnPrepareResponse { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public bool ServeUnknownFileTypes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } } diff --git a/src/Middleware/StaticFiles/samples/StaticFileSample/Startup.cs b/src/Middleware/StaticFiles/samples/StaticFileSample/Startup.cs index 861b7c396e09..cd504755daf2 100644 --- a/src/Middleware/StaticFiles/samples/StaticFileSample/Startup.cs +++ b/src/Middleware/StaticFiles/samples/StaticFileSample/Startup.cs @@ -12,12 +12,15 @@ public class Startup public void ConfigureServices(IServiceCollection services) { services.AddDirectoryBrowser(); + services.AddResponseCompression(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment host) { Console.WriteLine("webroot: " + host.WebRootPath); + app.UseResponseCompression(); + app.UseFileServer(new FileServerOptions { EnableDirectoryBrowsing = true diff --git a/src/Middleware/StaticFiles/samples/StaticFileSample/StaticFileSample.csproj b/src/Middleware/StaticFiles/samples/StaticFileSample/StaticFileSample.csproj index 830cfd2a917c..96301766f247 100644 --- a/src/Middleware/StaticFiles/samples/StaticFileSample/StaticFileSample.csproj +++ b/src/Middleware/StaticFiles/samples/StaticFileSample/StaticFileSample.csproj @@ -1,10 +1,11 @@ - + netcoreapp3.0 + diff --git a/src/Middleware/StaticFiles/src/StaticFileContext.cs b/src/Middleware/StaticFiles/src/StaticFileContext.cs index e38b600f7ba6..c21e3df7fe2b 100644 --- a/src/Middleware/StaticFiles/src/StaticFileContext.cs +++ b/src/Middleware/StaticFiles/src/StaticFileContext.cs @@ -322,6 +322,7 @@ public Task SendStatusAsync(int statusCode) public async Task SendAsync() { + SetCompressionMode(); ApplyResponseHeaders(Constants.Status200Ok); string physicalPath = _fileInfo.PhysicalPath; var sendFile = _context.Features.Get(); @@ -366,6 +367,7 @@ internal async Task SendRangeAsync() _responseHeaders.ContentRange = ComputeContentRange(_range, out var start, out var length); _response.ContentLength = length; + SetCompressionMode(); ApplyResponseHeaders(Constants.Status206PartialContent); string physicalPath = _fileInfo.PhysicalPath; @@ -404,5 +406,15 @@ private ContentRangeHeaderValue ComputeContentRange(RangeItemHeaderValue range, length = end - start + 1; return new ContentRangeHeaderValue(start, end, _length); } + + // Only called when we expect to serve the body. + private void SetCompressionMode() + { + var responseCompressionFeature = _context.Features.Get(); + if (responseCompressionFeature != null) + { + responseCompressionFeature.Mode = _options.HttpsCompression; + } + } } } diff --git a/src/Middleware/StaticFiles/src/StaticFileOptions.cs b/src/Middleware/StaticFiles/src/StaticFileOptions.cs index 01cef16b686a..6a1710d698b4 100644 --- a/src/Middleware/StaticFiles/src/StaticFileOptions.cs +++ b/src/Middleware/StaticFiles/src/StaticFileOptions.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles.Infrastructure; @@ -46,6 +47,15 @@ public StaticFileOptions(SharedOptions sharedOptions) : base(sharedOptions) /// public bool ServeUnknownFileTypes { get; set; } + /// + /// Indicates if files should be compressed for HTTPS requests when the Response Compression middleware is available. + /// The default value is . + /// + /// + /// Enabling compression on HTTPS requests for remotely manipulable content may expose security problems. + /// + public HttpsCompressionMode HttpsCompression { get; set; } = HttpsCompressionMode.Compress; + /// /// Called after the status code and headers have been set, but before the body has been written. /// This can be used to add or change the response headers. diff --git a/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs b/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs index f4fca87a3647..d61609657a26 100644 --- a/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs +++ b/src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Primitives; @@ -54,6 +56,54 @@ public void LookupFileInfo_ReturnsTrue_IfFileExists() Assert.True(result); } + [Fact] + public async Task EnablesHttpsCompression_IfMatched() + { + var options = new StaticFileOptions(); + var fileProvider = new TestFileProvider(); + fileProvider.AddFile("/foo.txt", new TestFileInfo + { + LastModified = new DateTimeOffset(2014, 1, 2, 3, 4, 5, TimeSpan.Zero) + }); + var pathString = new PathString("/test"); + var httpContext = new DefaultHttpContext(); + var httpsCompressionFeature = new TestHttpsCompressionFeature(); + httpContext.Features.Set(httpsCompressionFeature); + httpContext.Request.Path = new PathString("/test/foo.txt"); + var context = new StaticFileContext(httpContext, options, pathString, NullLogger.Instance, fileProvider, new FileExtensionContentTypeProvider()); + + context.ValidatePath(); + var result = context.LookupFileInfo(); + Assert.True(result); + + await context.SendAsync(); + + Assert.Equal(HttpsCompressionMode.Compress, httpsCompressionFeature.Mode); + } + + [Fact] + public void SkipsHttpsCompression_IfNotMatched() + { + var options = new StaticFileOptions(); + var fileProvider = new TestFileProvider(); + fileProvider.AddFile("/foo.txt", new TestFileInfo + { + LastModified = new DateTimeOffset(2014, 1, 2, 3, 4, 5, TimeSpan.Zero) + }); + var pathString = new PathString("/test"); + var httpContext = new DefaultHttpContext(); + var httpsCompressionFeature = new TestHttpsCompressionFeature(); + httpContext.Features.Set(httpsCompressionFeature); + httpContext.Request.Path = new PathString("/test/bar.txt"); + var context = new StaticFileContext(httpContext, options, pathString, NullLogger.Instance, fileProvider, new FileExtensionContentTypeProvider()); + + context.ValidatePath(); + var result = context.LookupFileInfo(); + Assert.False(result); + + Assert.Equal(HttpsCompressionMode.Default, httpsCompressionFeature.Mode); + } + private sealed class TestFileProvider : IFileProvider { private readonly Dictionary _files = new Dictionary(StringComparer.Ordinal); @@ -162,8 +212,13 @@ public bool IsDirectory public Stream CreateReadStream() { - throw new NotImplementedException(); + return new MemoryStream(); } } + + private class TestHttpsCompressionFeature : IHttpsCompressionFeature + { + public HttpsCompressionMode Mode { get; set; } + } } -} \ No newline at end of file +}