Support conditional compression #6925 (#8239)

This commit is contained in:
Chris Ross 2019-03-07 11:09:48 -08:00 committed by GitHub
parent 7fb3d57f54
commit 6db129a588
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 280 additions and 13 deletions

View File

@ -131,6 +131,12 @@ namespace Microsoft.AspNetCore.Http.Features
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.Generic.KeyValuePair<System.Type, object>>, System.Collections.IEnumerable
{
bool IsReadOnly { get; }
@ -207,6 +213,10 @@ namespace Microsoft.AspNetCore.Http.Features
{
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);

View File

@ -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
{
/// <summary>
/// Use to dynamically control response compression for HTTPS requests.
/// </summary>
public enum HttpsCompressionMode
{
/// <summary>
/// No value has been specified, use the configured defaults.
/// </summary>
Default = 0,
/// <summary>
/// Opts out of compression over HTTPS. Enabling compression on HTTPS requests for remotely manipulable content
/// may expose security problems.
/// </summary>
DoNotCompress,
/// <summary>
/// Opts into compression over HTTPS. Enabling compression on HTTPS requests for remotely manipulable content
/// may expose security problems.
/// </summary>
Compress,
}
}

View File

@ -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
{
/// <summary>
/// Configures response compression behavior for HTTPS on a per-request basis.
/// </summary>
public interface IHttpsCompressionFeature
{
/// <summary>
/// The <see cref="HttpsCompressionMode"/> to use.
/// </summary>
HttpsCompressionMode Mode { get; set; }
}
}

View File

@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.ResponseCompression
/// <summary>
/// Stream wrapper that create specific compression stream only if necessary.
/// </summary>
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 @@ namespace Microsoft.AspNetCore.ResponseCompression
return _compressionStream?.DisposeAsync() ?? new ValueTask();
}
HttpsCompressionMode IHttpsCompressionFeature.Mode { get; set; } = HttpsCompressionMode.Default;
public override bool CanRead => false;
public override bool CanSeek => false;

View File

@ -55,11 +55,13 @@ namespace Microsoft.AspNetCore.ResponseCompression
var originalBufferFeature = context.Features.Get<IHttpBufferingFeature>();
var originalSendFileFeature = context.Features.Get<IHttpSendFileFeature>();
var originalStartFeature = context.Features.Get<IHttpResponseStartFeature>();
var originalCompressionFeature = context.Features.Get<IHttpsCompressionFeature>();
var bodyWrapperStream = new BodyWrapperStream(context, bodyStream, _provider,
originalBufferFeature, originalSendFileFeature, originalStartFeature);
context.Response.Body = bodyWrapperStream;
context.Features.Set<IHttpBufferingFeature>(bodyWrapperStream);
context.Features.Set<IHttpsCompressionFeature>(bodyWrapperStream);
if (originalSendFileFeature != null)
{
context.Features.Set<IHttpSendFileFeature>(bodyWrapperStream);
@ -79,6 +81,7 @@ namespace Microsoft.AspNetCore.ResponseCompression
{
context.Response.Body = bodyStream;
context.Features.Set(originalBufferFeature);
context.Features.Set(originalCompressionFeature);
if (originalSendFileFeature != null)
{
context.Features.Set(originalSendFileFeature);

View File

@ -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 @@ namespace Microsoft.AspNetCore.ResponseCompression
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This can be overridden per request using <see cref="IHttpsCompressionFeature"/>.
/// </remarks>
public bool EnableForHttps { get; set; } = false;
/// <summary>

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
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 @@ namespace Microsoft.AspNetCore.ResponseCompression
/// <inheritdoc />
public virtual bool ShouldCompressResponse(HttpContext context)
{
var httpsMode = context.Features.Get<IHttpsCompressionFeature>()?.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 @@ namespace Microsoft.AspNetCore.ResponseCompression
/// <inheritdoc />
public bool CheckRequestAcceptsCompression(HttpContext context)
{
if (context.Request.IsHttps && !_enableForHttps)
{
_logger.NoCompressionForHttps();
return false;
}
if (string.IsNullOrEmpty(context.Request.Headers[HeaderNames.AcceptEncoding]))
{
_logger.NoAcceptEncoding();

View File

@ -453,7 +453,123 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests
}
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<ResponseCompressionProvider>,
TestSink.EnableWithTypeName<ResponseCompressionProvider>);
var loggerFactory = new TestLoggerFactory(sink, enabled: true);
var builder = new WebHostBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<ILoggerFactory>(loggerFactory);
services.AddResponseCompression(options =>
{
options.EnableForHttps = false;
options.MimeTypes = new[] { TextPlain };
});
})
.Configure(app =>
{
app.UseResponseCompression();
app.Run(context =>
{
var feature = context.Features.Get<IHttpsCompressionFeature>();
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<ResponseCompressionProvider>,
TestSink.EnableWithTypeName<ResponseCompressionProvider>);
var loggerFactory = new TestLoggerFactory(sink, enabled: true);
var builder = new WebHostBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<ILoggerFactory>(loggerFactory);
services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.MimeTypes = new[] { TextPlain };
});
})
.Configure(app =>
{
app.UseResponseCompression();
app.Run(context =>
{
var feature = context.Features.Get<IHttpsCompressionFeature>();
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");
}
}

View File

@ -55,6 +55,7 @@ namespace Microsoft.AspNetCore.Builder
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<Microsoft.AspNetCore.StaticFiles.StaticFileResponseContext> 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 { } }
}

View File

@ -12,12 +12,15 @@ namespace StaticFilesSample
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

View File

@ -1,10 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.ResponseCompression" />
<Reference Include="Microsoft.AspNetCore.StaticFiles" />
<Reference Include="Microsoft.AspNetCore.Server.IISIntegration" />
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />

View File

@ -322,6 +322,7 @@ namespace Microsoft.AspNetCore.StaticFiles
public async Task SendAsync()
{
SetCompressionMode();
ApplyResponseHeaders(Constants.Status200Ok);
string physicalPath = _fileInfo.PhysicalPath;
var sendFile = _context.Features.Get<IHttpSendFileFeature>();
@ -366,6 +367,7 @@ namespace Microsoft.AspNetCore.StaticFiles
_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 @@ namespace Microsoft.AspNetCore.StaticFiles
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<IHttpsCompressionFeature>();
if (responseCompressionFeature != null)
{
responseCompressionFeature.Mode = _options.HttpsCompression;
}
}
}
}

View File

@ -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 @@ namespace Microsoft.AspNetCore.Builder
/// </summary>
public bool ServeUnknownFileTypes { get; set; }
/// <summary>
/// Indicates if files should be compressed for HTTPS requests when the Response Compression middleware is available.
/// The default value is <see cref="HttpsCompressionMode.Compress"/>.
/// </summary>
/// <remarks>
/// Enabling compression on HTTPS requests for remotely manipulable content may expose security problems.
/// </remarks>
public HttpsCompressionMode HttpsCompression { get; set; } = HttpsCompressionMode.Compress;
/// <summary>
/// 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.

View File

@ -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 @@ namespace Microsoft.AspNetCore.StaticFiles
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<IHttpsCompressionFeature>(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<IHttpsCompressionFeature>(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<string, IFileInfo> _files = new Dictionary<string, IFileInfo>(StringComparer.Ordinal);
@ -162,8 +212,13 @@ namespace Microsoft.AspNetCore.StaticFiles
public Stream CreateReadStream()
{
throw new NotImplementedException();
return new MemoryStream();
}
}
private class TestHttpsCompressionFeature : IHttpsCompressionFeature
{
public HttpsCompressionMode Mode { get; set; }
}
}
}
}