diff --git a/AspNetCore.sln b/AspNetCore.sln index 02b6e7c51c77..2cb4e2f62880 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1654,6 +1654,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Compon EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.SdkAnalyzers.Tests", "src\Tools\SDK-Analyzers\Components\test\Microsoft.AspNetCore.Components.SdkAnalyzers.Tests.csproj", "{DC349A25-0DBF-4468-99E1-B95C22D3A7EF}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RequestDecompression", "RequestDecompression", "{5465F96F-33D5-454E-9C40-494E58AEEE5D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.RequestDecompression.Tests", "src\Middleware\RequestDecompression\test\Microsoft.AspNetCore.RequestDecompression.Tests.csproj", "{97996D39-7722-4AFC-A41A-AD61CA7A413D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RequestDecompressionSample", "src\Middleware\RequestDecompression\sample\RequestDecompressionSample.csproj", "{37144E52-611B-40E8-807C-2821F5A814CB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.RequestDecompression", "src\Middleware\RequestDecompression\src\Microsoft.AspNetCore.RequestDecompression.csproj", "{559FE354-7E08-4310-B4F3-AE30F34DEED5}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LinkabilityChecker", "LinkabilityChecker", "{94F95276-7CDF-44A8-B159-D09702EF6794}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinkabilityChecker", "src\Tools\LinkabilityChecker\LinkabilityChecker.csproj", "{EA7D844B-C180-41C7-9D55-273AD88BF71F}" @@ -1710,6 +1718,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Html.A EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RateLimiting", "RateLimiting", "{1D865E78-7A66-4CA9-92EE-2B350E45281F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.RequestDecompression.Microbenchmarks", "src\Middleware\RequestDecompression\perf\Microbenchmarks\Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj", "{3309FA1E-4E95-49E9-BE2A-827D01FD63C0}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-user-jwts", "src\Tools\dotnet-user-jwts\src\dotnet-user-jwts.csproj", "{B34CB502-0286-4939-B25F-45998528A802}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet-user-jwts", "dotnet-user-jwts", "{AB4B9E75-719C-4589-B852-20FBFD727730}" @@ -9967,6 +9977,54 @@ Global {DC349A25-0DBF-4468-99E1-B95C22D3A7EF}.Release|x64.Build.0 = Release|Any CPU {DC349A25-0DBF-4468-99E1-B95C22D3A7EF}.Release|x86.ActiveCfg = Release|Any CPU {DC349A25-0DBF-4468-99E1-B95C22D3A7EF}.Release|x86.Build.0 = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|arm64.ActiveCfg = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|arm64.Build.0 = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|x64.ActiveCfg = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|x64.Build.0 = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|x86.ActiveCfg = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|x86.Build.0 = Debug|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|Any CPU.Build.0 = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|arm64.ActiveCfg = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|arm64.Build.0 = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|x64.ActiveCfg = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|x64.Build.0 = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|x86.ActiveCfg = Release|Any CPU + {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Release|x86.Build.0 = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|arm64.ActiveCfg = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|arm64.Build.0 = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|x64.Build.0 = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Debug|x86.Build.0 = Debug|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|Any CPU.Build.0 = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|arm64.ActiveCfg = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|arm64.Build.0 = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|x64.ActiveCfg = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|x64.Build.0 = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|x86.ActiveCfg = Release|Any CPU + {37144E52-611B-40E8-807C-2821F5A814CB}.Release|x86.Build.0 = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|arm64.ActiveCfg = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|arm64.Build.0 = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|x64.ActiveCfg = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|x64.Build.0 = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|x86.ActiveCfg = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Debug|x86.Build.0 = Debug|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|Any CPU.Build.0 = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|arm64.ActiveCfg = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|arm64.Build.0 = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|x64.ActiveCfg = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|x64.Build.0 = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|x86.ActiveCfg = Release|Any CPU + {559FE354-7E08-4310-B4F3-AE30F34DEED5}.Release|x86.Build.0 = Release|Any CPU {EA7D844B-C180-41C7-9D55-273AD88BF71F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EA7D844B-C180-41C7-9D55-273AD88BF71F}.Debug|Any CPU.Build.0 = Debug|Any CPU {EA7D844B-C180-41C7-9D55-273AD88BF71F}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -10255,6 +10313,22 @@ Global {487EF7BE-5009-4C70-B79E-45519BDD9603}.Release|x64.Build.0 = Release|Any CPU {487EF7BE-5009-4C70-B79E-45519BDD9603}.Release|x86.ActiveCfg = Release|Any CPU {487EF7BE-5009-4C70-B79E-45519BDD9603}.Release|x86.Build.0 = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|arm64.ActiveCfg = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|arm64.Build.0 = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|x64.ActiveCfg = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|x64.Build.0 = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|x86.ActiveCfg = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Debug|x86.Build.0 = Debug|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|Any CPU.Build.0 = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|arm64.ActiveCfg = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|arm64.Build.0 = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|x64.ActiveCfg = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|x64.Build.0 = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|x86.ActiveCfg = Release|Any CPU + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0}.Release|x86.Build.0 = Release|Any CPU {B34CB502-0286-4939-B25F-45998528A802}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B34CB502-0286-4939-B25F-45998528A802}.Debug|Any CPU.Build.0 = Debug|Any CPU {B34CB502-0286-4939-B25F-45998528A802}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -11122,6 +11196,10 @@ Global {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} = {6C06163A-80E9-49C1-817C-B391852BA563} {825BCF97-67A9-4834-B3A8-C3DC97A90E41} = {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} {DC349A25-0DBF-4468-99E1-B95C22D3A7EF} = {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} + {5465F96F-33D5-454E-9C40-494E58AEEE5D} = {E5963C9F-20A6-4385-B364-814D2581FADF} + {97996D39-7722-4AFC-A41A-AD61CA7A413D} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} + {37144E52-611B-40E8-807C-2821F5A814CB} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} + {559FE354-7E08-4310-B4F3-AE30F34DEED5} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} {94F95276-7CDF-44A8-B159-D09702EF6794} = {0B200A66-B809-4ED3-A790-CB1C2E80975E} {EA7D844B-C180-41C7-9D55-273AD88BF71F} = {94F95276-7CDF-44A8-B159-D09702EF6794} {7A331A1C-E2C4-4E37-B0A0-B5AA10661229} = {8DAC59BE-CB96-4F04-909C-56C22E7665EB} @@ -11150,6 +11228,7 @@ Global {51D07AA9-6297-4F66-A7BD-71CE7E3F4A3F} = {0F84F170-57D0-496B-8E2C-7984178EF69F} {487EF7BE-5009-4C70-B79E-45519BDD9603} = {412D4C15-F48F-4DB1-940A-131D1AA87088} {1D865E78-7A66-4CA9-92EE-2B350E45281F} = {E5963C9F-20A6-4385-B364-814D2581FADF} + {3309FA1E-4E95-49E9-BE2A-827D01FD63C0} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} {B34CB502-0286-4939-B25F-45998528A802} = {AB4B9E75-719C-4589-B852-20FBFD727730} {AB4B9E75-719C-4589-B852-20FBFD727730} = {0B200A66-B809-4ED3-A790-CB1C2E80975E} {7F079E92-32D5-4257-B95B-CFFB0D49C160} = {7FD32066-C831-4E29-978C-9A2215E85C67} diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index d59faa1b0daa..c0842d685c55 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -90,6 +90,7 @@ + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index f32bf007b8a4..b727b8df0f26 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -77,6 +77,7 @@ + diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index 6b3978eb4f52..b36cb3fec262 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -75,6 +75,7 @@ static TestData() "Microsoft.AspNetCore.Mvc.ViewFeatures", "Microsoft.AspNetCore.Razor", "Microsoft.AspNetCore.Razor.Runtime", + "Microsoft.AspNetCore.RequestDecompression", "Microsoft.AspNetCore.ResponseCaching", "Microsoft.AspNetCore.ResponseCaching.Abstractions", "Microsoft.AspNetCore.ResponseCompression", @@ -210,6 +211,7 @@ static TestData() { "Microsoft.AspNetCore.Mvc.ViewFeatures", "7.0.0.0" }, { "Microsoft.AspNetCore.Razor", "7.0.0.0" }, { "Microsoft.AspNetCore.Razor.Runtime", "7.0.0.0" }, + { "Microsoft.AspNetCore.RequestDecompression", "7.0.0.0" }, { "Microsoft.AspNetCore.ResponseCaching", "7.0.0.0" }, { "Microsoft.AspNetCore.ResponseCaching.Abstractions", "7.0.0.0" }, { "Microsoft.AspNetCore.ResponseCompression", "7.0.0.0" }, diff --git a/src/Http/Http.Abstractions/src/Metadata/IRequestSizeLimitMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IRequestSizeLimitMetadata.cs new file mode 100644 index 000000000000..b90750ee6314 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IRequestSizeLimitMetadata.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Metadata; + +/// +/// Interface marking attributes that specify the maximum allowed size of the request body. +/// +public interface IRequestSizeLimitMetadata +{ + /// + /// The maximum allowed size of the current request body in bytes. + /// + long? MaxRequestBodySize { get; } +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index ae9027335d66..b26dc7ba75e8 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -12,6 +12,8 @@ Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string? +Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata +Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata.MaxRequestBodySize.get -> long? Microsoft.AspNetCore.Http.RouteHandlerContext Microsoft.AspNetCore.Http.RouteHandlerContext.EndpointMetadata.get -> Microsoft.AspNetCore.Http.EndpointMetadataCollection! Microsoft.AspNetCore.Http.RouteHandlerContext.MethodInfo.get -> System.Reflection.MethodInfo! diff --git a/src/Middleware/Middleware.slnf b/src/Middleware/Middleware.slnf index 2321ac94ea79..6033a2555e0d 100644 --- a/src/Middleware/Middleware.slnf +++ b/src/Middleware/Middleware.slnf @@ -78,6 +78,10 @@ "src\\Middleware\\MiddlewareAnalysis\\test\\Microsoft.AspNetCore.MiddlewareAnalysis.Tests.csproj", "src\\Middleware\\RateLimiting\\src\\Microsoft.AspNetCore.RateLimiting.csproj", "src\\Middleware\\RateLimiting\\test\\Microsoft.AspNetCore.RateLimiting.Tests.csproj", + "src\\Middleware\\RequestDecompression\\perf\\Microbenchmarks\\Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj", + "src\\Middleware\\RequestDecompression\\sample\\RequestDecompressionSample.csproj", + "src\\Middleware\\RequestDecompression\\src\\Microsoft.AspNetCore.RequestDecompression.csproj", + "src\\Middleware\\RequestDecompression\\test\\Microsoft.AspNetCore.RequestDecompression.Tests.csproj", "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\ResponseCaching\\samples\\ResponseCachingSample\\ResponseCachingSample.csproj", "src\\Middleware\\ResponseCaching\\src\\Microsoft.AspNetCore.ResponseCaching.csproj", @@ -117,4 +121,4 @@ "src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Middleware/RequestDecompression/perf/Microbenchmarks/AssemblyInfo.cs b/src/Middleware/RequestDecompression/perf/Microbenchmarks/AssemblyInfo.cs new file mode 100644 index 000000000000..09f49228e9e6 --- /dev/null +++ b/src/Middleware/RequestDecompression/perf/Microbenchmarks/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] diff --git a/src/Middleware/RequestDecompression/perf/Microbenchmarks/Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj b/src/Middleware/RequestDecompression/perf/Microbenchmarks/Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj new file mode 100644 index 000000000000..f40a5187122e --- /dev/null +++ b/src/Middleware/RequestDecompression/perf/Microbenchmarks/Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj @@ -0,0 +1,15 @@ + + + + Exe + $(DefaultNetCoreTargetFramework) + + + + + + + + + + diff --git a/src/Middleware/RequestDecompression/perf/Microbenchmarks/RequestDecompressionMiddlewareBenchmark.cs b/src/Middleware/RequestDecompression/perf/Microbenchmarks/RequestDecompressionMiddlewareBenchmark.cs new file mode 100644 index 000000000000..b78838985917 --- /dev/null +++ b/src/Middleware/RequestDecompression/perf/Microbenchmarks/RequestDecompressionMiddlewareBenchmark.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.RequestDecompression.Benchmarks; + +public class RequestDecompressionMiddlewareBenchmark +{ + private RequestDecompressionMiddleware _middleware; + + [GlobalSetup] + public void GlobalSetup() + { + var requestDecompressionProvider = new DefaultRequestDecompressionProvider( + NullLogger.Instance, + Options.Create(new RequestDecompressionOptions()) + ); + + _middleware = new RequestDecompressionMiddleware( + context => Task.CompletedTask, + NullLogger.Instance, + requestDecompressionProvider + ); + } + + [Params(true, false)] + public bool HasRequestSizeLimitMetadata { get; set; } + + [Benchmark] + public async Task HandleRequest_Compressed() + { + var context = CreateHttpContext(HasRequestSizeLimitMetadata); + + context.Request.Headers.ContentEncoding = "gzip"; + + await _middleware.Invoke(context); + } + + [Benchmark] + public async Task HandleRequest_Uncompressed() + { + var context = CreateHttpContext(HasRequestSizeLimitMetadata); + + await _middleware.Invoke(context); + } + + private static DefaultHttpContext CreateHttpContext(bool hasRequestSizeLimitMetadata) + { + var features = new FeatureCollection(); + features.Set(new HttpRequestFeature()); + features.Set(new HttpResponseFeature()); + features.Set(new MaxRequestBodySizeFeature()); + features.Set(new EndpointFeature(hasRequestSizeLimitMetadata)); + var context = new DefaultHttpContext(features); + return context; + } + + private sealed class MaxRequestBodySizeFeature : IHttpMaxRequestBodySizeFeature + { + public bool IsReadOnly => false; + + public long? MaxRequestBodySize { get; set; } = 30_000_000; + } + + private sealed class EndpointFeature : IEndpointFeature + { + public Endpoint Endpoint { get; set; } + + public EndpointFeature(bool hasRequestSizeLimitMetadata) + { + var metadataCollection = hasRequestSizeLimitMetadata + ? new EndpointMetadataCollection(new SizeLimitMetadata()) + : new EndpointMetadataCollection(); + + Endpoint = new Endpoint( + requestDelegate: null, + metadata: metadataCollection, + displayName: null); + } + } + + private sealed class SizeLimitMetadata : IRequestSizeLimitMetadata + { + public long? MaxRequestBodySize { get; set; } = 50_000_000; + } +} diff --git a/src/Middleware/RequestDecompression/sample/CustomDecompressionProvider.cs b/src/Middleware/RequestDecompression/sample/CustomDecompressionProvider.cs new file mode 100644 index 000000000000..9bfa11acf207 --- /dev/null +++ b/src/Middleware/RequestDecompression/sample/CustomDecompressionProvider.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.RequestDecompression; + +namespace RequestDecompressionSample; + +public class CustomDecompressionProvider : IDecompressionProvider +{ + public Stream GetDecompressionStream(Stream stream) + { + // Create a custom decompression stream wrapper here. + return stream; + } +} diff --git a/src/Middleware/RequestDecompression/sample/Properties/launchsettings.json b/src/Middleware/RequestDecompression/sample/Properties/launchsettings.json new file mode 100644 index 000000000000..26a370a87fb6 --- /dev/null +++ b/src/Middleware/RequestDecompression/sample/Properties/launchsettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:6164/", + "sslPort": 0 + } + }, + "profiles": { + "RequestDecompressionSample": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "http://localhost:5000/", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/src/Middleware/RequestDecompression/sample/RequestDecompressionSample.csproj b/src/Middleware/RequestDecompression/sample/RequestDecompressionSample.csproj new file mode 100644 index 000000000000..fec9ada30879 --- /dev/null +++ b/src/Middleware/RequestDecompression/sample/RequestDecompressionSample.csproj @@ -0,0 +1,13 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + \ No newline at end of file diff --git a/src/Middleware/RequestDecompression/sample/Startup.cs b/src/Middleware/RequestDecompression/sample/Startup.cs new file mode 100644 index 000000000000..55d99ab8ec2f --- /dev/null +++ b/src/Middleware/RequestDecompression/sample/Startup.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace RequestDecompressionSample; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddRequestDecompression(options => + { + options.DecompressionProviders.Add("custom", new CustomDecompressionProvider()); + }); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRequestDecompression(); + app.Map("/test", testApp => + { + testApp.Run(async context => + { + using var reader = new StreamReader(context.Request.Body); + var decompressedBody = await reader.ReadToEndAsync(context.RequestAborted); + + await context.Response.WriteAsync(decompressedBody, context.RequestAborted); + }); + }); + } + + public static Task Main(string[] args) + { + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel() + .ConfigureLogging(factory => + { + factory.AddConsole() + .SetMinimumLevel(LogLevel.Debug); + }) + .UseStartup(); + }).Build(); + + return host.RunAsync(); + } +} diff --git a/src/Middleware/RequestDecompression/src/BrotliDecompressionProvider.cs b/src/Middleware/RequestDecompression/src/BrotliDecompressionProvider.cs new file mode 100644 index 000000000000..7b3b28837fc2 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/BrotliDecompressionProvider.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Compression; + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +/// Brotli decompression provider. +/// +internal sealed class BrotliDecompressionProvider : IDecompressionProvider +{ + /// + public Stream GetDecompressionStream(Stream stream) + { + return new BrotliStream(stream, CompressionMode.Decompress, leaveOpen: true); + } +} diff --git a/src/Middleware/RequestDecompression/src/DefaultRequestDecompressionProvider.cs b/src/Middleware/RequestDecompression/src/DefaultRequestDecompressionProvider.cs new file mode 100644 index 000000000000..3178fcc5d288 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/DefaultRequestDecompressionProvider.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +internal sealed partial class DefaultRequestDecompressionProvider : IRequestDecompressionProvider +{ + private readonly ILogger _logger; + private readonly IDictionary _providers; + + public DefaultRequestDecompressionProvider( + ILogger logger, + IOptions options) + { + if (logger is null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + _logger = logger; + _providers = options.Value.DecompressionProviders; + } + + /// + public Stream? GetDecompressionStream(HttpContext context) + { + var encodings = context.Request.Headers.ContentEncoding; + + if (StringValues.IsNullOrEmpty(encodings)) + { + Log.NoContentEncoding(_logger); + return null; + } + + if (encodings.Count > 1) + { + Log.MultipleContentEncodingsSpecified(_logger); + return null; + } + + string encodingName = encodings!; + + if (_providers.TryGetValue(encodingName, out var matchingProvider)) + { + Log.DecompressingWith(_logger, encodingName); + + context.Request.Headers.Remove(HeaderNames.ContentEncoding); + + return matchingProvider.GetDecompressionStream(context.Request.Body); + } + + Log.NoDecompressionProvider(_logger); + return null; + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Trace, "The Content-Encoding header is empty or not specified. Skipping request decompression.", EventName = "NoContentEncoding")] + public static partial void NoContentEncoding(ILogger logger); + + [LoggerMessage(2, LogLevel.Debug, "Request decompression is not supported for multiple Content-Encodings.", EventName = "MultipleContentEncodingsSpecified")] + public static partial void MultipleContentEncodingsSpecified(ILogger logger); + + [LoggerMessage(3, LogLevel.Debug, "No matching request decompression provider found.", EventName = "NoDecompressionProvider")] + public static partial void NoDecompressionProvider(ILogger logger); + + public static void DecompressingWith(ILogger logger, string contentEncoding) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + DecompressingWithCore(logger, contentEncoding.ToLowerInvariant()); + } + } + + [LoggerMessage(4, LogLevel.Debug, "The request will be decompressed with '{ContentEncoding}'.", EventName = "DecompressingWith", SkipEnabledCheck = true)] + private static partial void DecompressingWithCore(ILogger logger, string contentEncoding); + } +} diff --git a/src/Middleware/RequestDecompression/src/DeflateDecompressionProvider.cs b/src/Middleware/RequestDecompression/src/DeflateDecompressionProvider.cs new file mode 100644 index 000000000000..6291f15b2d71 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/DeflateDecompressionProvider.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Compression; + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +/// DEFLATE decompression provider. +/// +internal sealed class DeflateDecompressionProvider : IDecompressionProvider +{ + /// + public Stream GetDecompressionStream(Stream stream) + { + return new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + } +} diff --git a/src/Middleware/RequestDecompression/src/GZipDecompressionProvider.cs b/src/Middleware/RequestDecompression/src/GZipDecompressionProvider.cs new file mode 100644 index 000000000000..74ac6835f81c --- /dev/null +++ b/src/Middleware/RequestDecompression/src/GZipDecompressionProvider.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Compression; + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +/// GZip decompression provider. +/// +internal sealed class GZipDecompressionProvider : IDecompressionProvider +{ + /// + public Stream GetDecompressionStream(Stream stream) + { + return new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true); + } +} diff --git a/src/Middleware/RequestDecompression/src/IDecompressionProvider.cs b/src/Middleware/RequestDecompression/src/IDecompressionProvider.cs new file mode 100644 index 000000000000..f59be0fe87b1 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/IDecompressionProvider.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +/// Provides a specific decompression implementation to decompress HTTP request bodies. +/// +public interface IDecompressionProvider +{ + /// + /// Creates a new decompression stream. + /// + /// The compressed request body stream. + /// The decompression stream. + Stream GetDecompressionStream(Stream stream); +} diff --git a/src/Middleware/RequestDecompression/src/IRequestDecompressionProvider.cs b/src/Middleware/RequestDecompression/src/IRequestDecompressionProvider.cs new file mode 100644 index 000000000000..6387b91a309a --- /dev/null +++ b/src/Middleware/RequestDecompression/src/IRequestDecompressionProvider.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +/// Used to examine requests to see if decompression should be used. +/// +public interface IRequestDecompressionProvider +{ + /// + /// Examines the request and selects an acceptable decompression provider, if any. + /// + /// The . + /// The decompression stream when the provider is capable of decompressing the HTTP request body, otherwise . + Stream? GetDecompressionStream(HttpContext context); +} diff --git a/src/Middleware/RequestDecompression/src/Microsoft.AspNetCore.RequestDecompression.csproj b/src/Middleware/RequestDecompression/src/Microsoft.AspNetCore.RequestDecompression.csproj new file mode 100644 index 000000000000..3089a5ca1622 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/Microsoft.AspNetCore.RequestDecompression.csproj @@ -0,0 +1,24 @@ + + + + ASP.NET Core middleware for HTTP Request decompression. + $(DefaultNetCoreTargetFramework) + true + true + aspnetcore + false + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Middleware/RequestDecompression/src/PublicAPI.Shipped.txt b/src/Middleware/RequestDecompression/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Middleware/RequestDecompression/src/PublicAPI.Unshipped.txt b/src/Middleware/RequestDecompression/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..14f9b5dc496a --- /dev/null +++ b/src/Middleware/RequestDecompression/src/PublicAPI.Unshipped.txt @@ -0,0 +1,13 @@ +#nullable enable +Microsoft.AspNetCore.Builder.RequestDecompressionBuilderExtensions +Microsoft.AspNetCore.RequestDecompression.IDecompressionProvider +Microsoft.AspNetCore.RequestDecompression.IDecompressionProvider.GetDecompressionStream(System.IO.Stream! stream) -> System.IO.Stream! +Microsoft.AspNetCore.RequestDecompression.IRequestDecompressionProvider +Microsoft.AspNetCore.RequestDecompression.IRequestDecompressionProvider.GetDecompressionStream(Microsoft.AspNetCore.Http.HttpContext! context) -> System.IO.Stream? +Microsoft.AspNetCore.RequestDecompression.RequestDecompressionOptions +Microsoft.AspNetCore.RequestDecompression.RequestDecompressionOptions.DecompressionProviders.get -> System.Collections.Generic.IDictionary! +Microsoft.AspNetCore.RequestDecompression.RequestDecompressionOptions.RequestDecompressionOptions() -> void +Microsoft.Extensions.DependencyInjection.RequestDecompressionServiceExtensions +static Microsoft.AspNetCore.Builder.RequestDecompressionBuilderExtensions.UseRequestDecompression(this Microsoft.AspNetCore.Builder.IApplicationBuilder! builder) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! +static Microsoft.Extensions.DependencyInjection.RequestDecompressionServiceExtensions.AddRequestDecompression(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.RequestDecompressionServiceExtensions.AddRequestDecompression(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Middleware/RequestDecompression/src/RequestDecompressionBuilderExtensions.cs b/src/Middleware/RequestDecompression/src/RequestDecompressionBuilderExtensions.cs new file mode 100644 index 000000000000..c67d2bdfd1d0 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/RequestDecompressionBuilderExtensions.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.RequestDecompression; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Extension methods for the HTTP request decompression middleware. +/// +public static class RequestDecompressionBuilderExtensions +{ + /// + /// Adds middleware for dynamically decompressing HTTP request bodies. + /// + /// The instance this method extends. + public static IApplicationBuilder UseRequestDecompression(this IApplicationBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.UseMiddleware(); + } +} diff --git a/src/Middleware/RequestDecompression/src/RequestDecompressionMiddleware.cs b/src/Middleware/RequestDecompression/src/RequestDecompressionMiddleware.cs new file mode 100644 index 000000000000..253b1d238884 --- /dev/null +++ b/src/Middleware/RequestDecompression/src/RequestDecompressionMiddleware.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +/// Enables HTTP request decompression. +/// +internal sealed partial class RequestDecompressionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IRequestDecompressionProvider _provider; + + /// + /// Initialize the request decompression middleware. + /// + /// The delegate representing the remaining middleware in the request pipeline. + /// The logger. + /// The . + public RequestDecompressionMiddleware( + RequestDelegate next, + ILogger logger, + IRequestDecompressionProvider provider) + { + if (next is null) + { + throw new ArgumentNullException(nameof(next)); + } + + if (logger is null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + + _next = next; + _logger = logger; + _provider = provider; + } + + /// + /// Invoke the middleware. + /// + /// The . + /// A task that represents the execution of this middleware. + public Task Invoke(HttpContext context) + { + SetMaxRequestBodySize(context); + + var decompressionStream = _provider.GetDecompressionStream(context); + if (decompressionStream is null) + { + return _next(context); + } + + return InvokeCore(context, decompressionStream); + } + + private async Task InvokeCore(HttpContext context, Stream decompressionStream) + { + var request = context.Request.Body; + try + { + var sizeLimit = + context.GetEndpoint()?.Metadata?.GetMetadata()?.MaxRequestBodySize + ?? context.Features.Get()?.MaxRequestBodySize; + + context.Request.Body = new SizeLimitedStream(decompressionStream, sizeLimit); + await _next(context); + } + finally + { + context.Request.Body = request; + await decompressionStream.DisposeAsync(); + } + } + + private void SetMaxRequestBodySize(HttpContext context) + { + var sizeLimitMetadata = context.GetEndpoint()?.Metadata?.GetMetadata(); + if (sizeLimitMetadata == null) + { + Log.MetadataNotFound(_logger); + return; + } + + var maxRequestBodySizeFeature = context.Features.Get(); + if (maxRequestBodySizeFeature == null) + { + Log.FeatureNotFound(_logger); + } + else if (maxRequestBodySizeFeature.IsReadOnly) + { + Log.FeatureIsReadOnly(_logger); + } + else + { + var maxRequestBodySize = sizeLimitMetadata.MaxRequestBodySize; + maxRequestBodySizeFeature.MaxRequestBodySize = maxRequestBodySize; + + if (maxRequestBodySize.HasValue) + { + Log.MaxRequestBodySizeSet(_logger, + maxRequestBodySize.Value.ToString(CultureInfo.InvariantCulture)); + } + else + { + Log.MaxRequestBodySizeDisabled(_logger); + } + } + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, $"The endpoint does not specify the {nameof(IRequestSizeLimitMetadata)}.", EventName = "MetadataNotFound")] + public static partial void MetadataNotFound(ILogger logger); + + [LoggerMessage(2, LogLevel.Warning, $"A request body size limit could not be applied. This server does not support the {nameof(IHttpMaxRequestBodySizeFeature)}.", EventName = "FeatureNotFound")] + public static partial void FeatureNotFound(ILogger logger); + + [LoggerMessage(3, LogLevel.Warning, $"A request body size limit could not be applied. The {nameof(IHttpMaxRequestBodySizeFeature)} for the server is read-only.", EventName = "FeatureIsReadOnly")] + public static partial void FeatureIsReadOnly(ILogger logger); + + [LoggerMessage(4, LogLevel.Debug, "The maximum request body size has been set to {RequestSize}.", EventName = "MaxRequestBodySizeSet")] + public static partial void MaxRequestBodySizeSet(ILogger logger, string requestSize); + + [LoggerMessage(5, LogLevel.Debug, "The maximum request body size as been disabled.", EventName = "MaxRequestBodySizeDisabled")] + public static partial void MaxRequestBodySizeDisabled(ILogger logger); + } +} diff --git a/src/Middleware/RequestDecompression/src/RequestDecompressionOptions.cs b/src/Middleware/RequestDecompression/src/RequestDecompressionOptions.cs new file mode 100644 index 000000000000..b6821e90469b --- /dev/null +++ b/src/Middleware/RequestDecompression/src/RequestDecompressionOptions.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.RequestDecompression; + +/// +/// Options for the HTTP request decompression middleware. +/// +public sealed class RequestDecompressionOptions +{ + /// + /// The types to use for request decompression. + /// + public IDictionary DecompressionProviders { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["br"] = new BrotliDecompressionProvider(), + ["deflate"] = new DeflateDecompressionProvider(), + ["gzip"] = new GZipDecompressionProvider() + }; +} diff --git a/src/Middleware/RequestDecompression/src/RequestDecompressionServiceExtensions.cs b/src/Middleware/RequestDecompression/src/RequestDecompressionServiceExtensions.cs new file mode 100644 index 000000000000..7996ef88a93a --- /dev/null +++ b/src/Middleware/RequestDecompression/src/RequestDecompressionServiceExtensions.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.RequestDecompression; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for the HTTP request decompression middleware. +/// +public static class RequestDecompressionServiceExtensions +{ + /// + /// Add request decompression services. + /// + /// The for adding services. + /// The . + public static IServiceCollection AddRequestDecompression(this IServiceCollection services) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.TryAddSingleton(); + return services; + } + + /// + /// Add request decompression services and configure the related options. + /// + /// The for adding services. + /// A delegate to configure the . + /// The . + public static IServiceCollection AddRequestDecompression(this IServiceCollection services, Action configureOptions) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configureOptions is null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + services.Configure(configureOptions); + services.TryAddSingleton(); + return services; + } +} diff --git a/src/Middleware/RequestDecompression/src/SizeLimitedStream.cs b/src/Middleware/RequestDecompression/src/SizeLimitedStream.cs new file mode 100644 index 000000000000..2c53bd73077d --- /dev/null +++ b/src/Middleware/RequestDecompression/src/SizeLimitedStream.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.RequestDecompression; + +internal sealed class SizeLimitedStream : Stream +{ + private readonly Stream _innerStream; + private readonly long? _sizeLimit; + + private long _totalBytesRead; + + public SizeLimitedStream(Stream innerStream, long? sizeLimit) + { + if (innerStream is null) + { + throw new ArgumentNullException(nameof(innerStream)); + } + + _innerStream = innerStream; + _sizeLimit = sizeLimit; + } + + public override bool CanRead => _innerStream.CanRead; + + public override bool CanSeek => _innerStream.CanSeek; + + public override bool CanWrite => _innerStream.CanWrite; + + public override long Length => _innerStream.Length; + + public override long Position + { + get + { + return _innerStream.Position; + } + set + { + _innerStream.Position = value; + } + } + + public override void Flush() + { + _innerStream.Flush(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesRead = _innerStream.Read(buffer, offset, count); + + _totalBytesRead += bytesRead; + if (_totalBytesRead > _sizeLimit) + { + throw new InvalidOperationException("The maximum number of bytes have been read."); + } + + return bytesRead; + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _innerStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _innerStream.Write(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var bytesRead = await _innerStream.ReadAsync(buffer, cancellationToken); + + _totalBytesRead += bytesRead; + if (_totalBytesRead > _sizeLimit) + { + throw new InvalidOperationException("The maximum number of bytes have been read."); + } + + return bytesRead; + } +} diff --git a/src/Middleware/RequestDecompression/test/DefaultRequestDecompressionProviderTests.cs b/src/Middleware/RequestDecompression/test/DefaultRequestDecompressionProviderTests.cs new file mode 100644 index 000000000000..1f39cfef3826 --- /dev/null +++ b/src/Middleware/RequestDecompression/test/DefaultRequestDecompressionProviderTests.cs @@ -0,0 +1,176 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using Microsoft.Extensions.Primitives; +using System.IO.Compression; + +namespace Microsoft.AspNetCore.RequestDecompression.Tests; + +public class DefaultRequestDecompressionProviderTests +{ + [Theory] + [InlineData("br", typeof(BrotliStream))] + [InlineData("BR", typeof(BrotliStream))] + [InlineData("deflate", typeof(DeflateStream))] + [InlineData("DEFLATE", typeof(DeflateStream))] + [InlineData("gzip", typeof(GZipStream))] + [InlineData("GZIP", typeof(GZipStream))] + public void GetDecompressionProvider_SupportedContentEncoding_ReturnsProvider( + string contentEncoding, + Type expectedProviderType) + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Add(HeaderNames.ContentEncoding, contentEncoding); + + var (logger, sink) = GetTestLogger(); + var options = Options.Create(new RequestDecompressionOptions()); + + var provider = new DefaultRequestDecompressionProvider(logger, options); + + // Act + var matchingProvider = provider.GetDecompressionStream(httpContext); + + // Assert + Assert.NotNull(matchingProvider); + Assert.IsType(expectedProviderType, matchingProvider); + + var logMessages = sink.Writes.ToList(); + AssertLog(logMessages.Single(), LogLevel.Debug, + $"The request will be decompressed with '{contentEncoding.ToLowerInvariant()}'."); + + var contentEncodingHeader = httpContext.Request.Headers.ContentEncoding; + Assert.Empty(contentEncodingHeader); + } + + [Fact] + public void GetDecompressionProvider_NoContentEncoding_ReturnsNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + + var (logger, sink) = GetTestLogger(); + var options = Options.Create(new RequestDecompressionOptions()); + + var provider = new DefaultRequestDecompressionProvider(logger, options); + + // Act + var matchingProvider = provider.GetDecompressionStream(httpContext); + + // Assert + Assert.Null(matchingProvider); + + var logMessages = sink.Writes.ToList(); + AssertLog(logMessages.Single(), LogLevel.Trace, + "The Content-Encoding header is empty or not specified. Skipping request decompression."); + + var contentEncodingHeader = httpContext.Request.Headers.ContentEncoding; + Assert.Empty(contentEncodingHeader); + } + + [Fact] + public void GetDecompressionProvider_UnsupportedContentEncoding_ReturnsNull() + { + // Arrange + var contentEncoding = "custom"; + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Add(HeaderNames.ContentEncoding, contentEncoding); + + var (logger, sink) = GetTestLogger(); + var options = Options.Create(new RequestDecompressionOptions()); + + var provider = new DefaultRequestDecompressionProvider(logger, options); + + // Act + var matchingProvider = provider.GetDecompressionStream(httpContext); + + // Assert + Assert.Null(matchingProvider); + + var logMessages = sink.Writes.ToList(); + AssertLog(logMessages.Single(), + LogLevel.Debug, "No matching request decompression provider found."); + + var contentEncodingHeader = httpContext.Request.Headers.ContentEncoding; + Assert.Equal(contentEncoding, contentEncodingHeader); + } + + [Fact] + public void GetDecompressionProvider_MultipleContentEncodings_ReturnsNull() + { + // Arrange + var contentEncodings = new StringValues(new[] { "br", "gzip" }); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Add(HeaderNames.ContentEncoding, contentEncodings); + + var (logger, sink) = GetTestLogger(); + var options = Options.Create(new RequestDecompressionOptions()); + + var provider = new DefaultRequestDecompressionProvider(logger, options); + + // Act + var matchingProvider = provider.GetDecompressionStream(httpContext); + + // Assert + Assert.Null(matchingProvider); + + var logMessages = sink.Writes.ToList(); + AssertLog(logMessages.Single(), LogLevel.Debug, + "Request decompression is not supported for multiple Content-Encodings."); + + var contentEncodingHeader = httpContext.Request.Headers.ContentEncoding; + Assert.Equal(contentEncodings, contentEncodingHeader); + } + + [Fact] + public void Ctor_NullLogger_Throws() + { + // Arrange + var (logger, _) = GetTestLogger(); + IOptions options = null; + + // Act + Assert + Assert.Throws(() => + { + new DefaultRequestDecompressionProvider(logger, options); + }); + } + + [Fact] + public void Ctor_NullOptions_Throws() + { + // Arrange + ILogger logger = null; + var options = Options.Create(new RequestDecompressionOptions()); + + // Act + Assert + Assert.Throws(() => + { + new DefaultRequestDecompressionProvider(logger, options); + }); + } + + private static (ILogger, TestSink) GetTestLogger() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var logger = loggerFactory.CreateLogger(); + + return (logger, sink); + } + + private static void AssertLog(WriteContext log, LogLevel level, string message) + { + Assert.Equal(level, log.LogLevel); + Assert.Equal(message, log.State.ToString()); + } +} diff --git a/src/Middleware/RequestDecompression/test/Microsoft.AspNetCore.RequestDecompression.Tests.csproj b/src/Middleware/RequestDecompression/test/Microsoft.AspNetCore.RequestDecompression.Tests.csproj new file mode 100644 index 000000000000..21eb7830cc38 --- /dev/null +++ b/src/Middleware/RequestDecompression/test/Microsoft.AspNetCore.RequestDecompression.Tests.csproj @@ -0,0 +1,14 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + \ No newline at end of file diff --git a/src/Middleware/RequestDecompression/test/RequestDecompressionBuilderExtensionsTests.cs b/src/Middleware/RequestDecompression/test/RequestDecompressionBuilderExtensionsTests.cs new file mode 100644 index 000000000000..b30aaf0122e4 --- /dev/null +++ b/src/Middleware/RequestDecompression/test/RequestDecompressionBuilderExtensionsTests.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; + +namespace Microsoft.AspNetCore.RequestDecompression.Tests; + +public class RequestDecompressionBuilderExtensionsTests +{ + [Fact] + public void UseRequestDecompression_NullApplicationBuilder_Throws() + { + // Arrange + IApplicationBuilder builder = null; + + // Act + Assert + Assert.Throws(() => + { + builder.UseRequestDecompression(); + }); + } +} diff --git a/src/Middleware/RequestDecompression/test/RequestDecompressionMiddlewareTests.cs b/src/Middleware/RequestDecompression/test/RequestDecompressionMiddlewareTests.cs new file mode 100644 index 000000000000..e28e0b6c6b5c --- /dev/null +++ b/src/Middleware/RequestDecompression/test/RequestDecompressionMiddlewareTests.cs @@ -0,0 +1,945 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.IO.Compression; +using System.Net.Http; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.RequestDecompression.Tests; + +public class RequestDecompressionMiddlewareTests +{ + private const string TestRequestBodyData = "Test Request Body Data"; + + private static byte[] GetUncompressedContent(string input = TestRequestBodyData) + { + return Encoding.UTF8.GetBytes(input); + } + + private static async Task GetCompressedContent( + Func compressorDelegate, + byte[] uncompressedBytes) + { + await using var uncompressedStream = new MemoryStream(uncompressedBytes); + + await using var compressedStream = new MemoryStream(); + await using (var compressor = compressorDelegate(compressedStream)) + { + await uncompressedStream.CopyToAsync(compressor); + } + + return compressedStream.ToArray(); + } + + private static async Task GetBrotliCompressedContent(byte[] uncompressedBytes) + { + static Stream compressorDelegate(Stream compressedContent) => + new BrotliStream(compressedContent, CompressionMode.Compress); + + return await GetCompressedContent(compressorDelegate, uncompressedBytes); + } + + private static async Task GetDeflateCompressedContent(byte[] uncompressedBytes) + { + static Stream compressorDelegate(Stream compressedContent) => + new DeflateStream(compressedContent, CompressionMode.Compress); + + return await GetCompressedContent(compressorDelegate, uncompressedBytes); + } + + private static async Task GetGZipCompressedContent(byte[] uncompressedBytes) + { + static Stream compressorDelegate(Stream compressedContent) => + new GZipStream(compressedContent, CompressionMode.Compress); + + return await GetCompressedContent(compressorDelegate, uncompressedBytes); + } + + [Fact] + public async Task Request_ContentEncodingBrotli_Decompressed() + { + // Arrange + var contentEncoding = "br"; + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetBrotliCompressedContent(uncompressedBytes); + + // Act + var (logMessages, decompressedBytes) = await InvokeMiddleware(compressedBytes, new[] { contentEncoding }); + + // Assert + AssertDecompressedWithLog(logMessages, contentEncoding.ToLowerInvariant()); + Assert.Equal(uncompressedBytes, decompressedBytes); + } + + [Fact] + public async Task Request_ContentEncodingDeflate_Decompressed() + { + // Arrange + var contentEncoding = "deflate"; + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetDeflateCompressedContent(uncompressedBytes); + + // Act + var (logMessages, decompressedBytes) = await InvokeMiddleware(compressedBytes, new[] { contentEncoding }); + + // Assert + AssertDecompressedWithLog(logMessages, contentEncoding.ToLowerInvariant()); + Assert.Equal(uncompressedBytes, decompressedBytes); + } + + [Fact] + public async Task Request_ContentEncodingGzip_Decompressed() + { + // Arrange + var contentEncoding = "gzip"; + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + + // Act + var (logMessages, decompressedBytes) = await InvokeMiddleware(compressedBytes, new[] { contentEncoding }); + + // Assert + AssertDecompressedWithLog(logMessages, contentEncoding.ToLowerInvariant()); + Assert.Equal(uncompressedBytes, decompressedBytes); + } + + [Fact] + public async Task Request_NoContentEncoding_NotDecompressed() + { + // Arrange + var uncompressedBytes = GetUncompressedContent(); + + // Act + var (logMessages, outputBytes) = await InvokeMiddleware(uncompressedBytes); + + // Assert + var logMessage = Assert.Single(logMessages); + AssertLog(logMessage, LogLevel.Trace, "The Content-Encoding header is empty or not specified. Skipping request decompression."); + Assert.Equal(uncompressedBytes, outputBytes); + } + + [Fact] + public async Task Request_UnsupportedContentEncoding_NotDecompressed() + { + // Arrange + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + var contentEncoding = "custom"; + + // Act + var (logMessages, outputBytes) = await InvokeMiddleware(compressedBytes, new[] { contentEncoding }); + + // Assert + AssertNoDecompressionProviderLog(logMessages); + Assert.Equal(compressedBytes, outputBytes); + } + + [Fact] + public async Task Request_MultipleContentEncodings_NotDecompressed() + { + // Arrange + var uncompressedBytes = GetUncompressedContent(); + var inputBytes = await GetGZipCompressedContent(uncompressedBytes); + var contentEncodings = new[] { "br", "gzip" }; + + // Act + var (logMessages, outputBytes) = await InvokeMiddleware(inputBytes, contentEncodings); + + // Assert + var logMessage = Assert.Single(logMessages); + AssertLog(logMessage, LogLevel.Debug, "Request decompression is not supported for multiple Content-Encodings."); + Assert.Equal(inputBytes, outputBytes); + } + + [Fact] + public async Task Request_MiddlewareAddedMultipleTimes_OnlyDecompressedOnce() + { + // Arrange + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + var contentEncoding = "gzip"; + + var decompressedBytes = Array.Empty(); + + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRequestDecompression(); + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.Use((context, next) => + { + context.Features.Set( + new FakeHttpMaxRequestBodySizeFeature()); + return next(context); + }); + app.UseRequestDecompression(); + app.UseRequestDecompression(); + app.Run(async context => + { + await using var ms = new MemoryStream(); + await context.Request.Body.CopyToAsync(ms, context.RequestAborted); + decompressedBytes = ms.ToArray(); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new ByteArrayContent(compressedBytes); + request.Content.Headers.ContentEncoding.Add(contentEncoding); + + // Act + await client.SendAsync(request); + + // Assert + var logMessages = sink.Writes.ToList(); + + Assert.Equal(2, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Debug, $"The request will be decompressed with '{contentEncoding}'."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "The Content-Encoding header is empty or not specified. Skipping request decompression."); + + Assert.Equal(uncompressedBytes, decompressedBytes); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Request_Decompressed_ContentEncodingHeaderRemoved(bool isDecompressed) + { + // Arrange + var contentEncoding = isDecompressed ? "gzip" : "custom"; + var contentEncodingHeader = new StringValues(); + + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + + var outputBytes = Array.Empty(); + + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRequestDecompression(); + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.Use((context, next) => + { + context.Features.Set( + new FakeHttpMaxRequestBodySizeFeature()); + return next(context); + }); + app.UseRequestDecompression(); + app.Run(async context => + { + contentEncodingHeader = context.Request.Headers.ContentEncoding; + + await using var ms = new MemoryStream(); + await context.Request.Body.CopyToAsync(ms, context.RequestAborted); + outputBytes = ms.ToArray(); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new ByteArrayContent(compressedBytes); + request.Content.Headers.ContentEncoding.Add(contentEncoding); + + // Act + await client.SendAsync(request); + + // Assert + var logMessages = sink.Writes.ToList(); + + if (isDecompressed) + { + Assert.Empty(contentEncodingHeader); + + AssertDecompressedWithLog(logMessages, contentEncoding); + Assert.Equal(uncompressedBytes, outputBytes); + } + else + { + Assert.Equal(contentEncoding, contentEncodingHeader); + + AssertNoDecompressionProviderLog(logMessages); + Assert.Equal(compressedBytes, outputBytes); + } + } + + [Fact] + public async Task Request_InvalidDataForContentEncoding_ThrowsInvalidOperationException() + { + // Arrange + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + var contentEncoding = "br"; + + Exception exception = null; + + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRequestDecompression(); + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.Use((context, next) => + { + context.Features.Set( + new FakeHttpMaxRequestBodySizeFeature()); + return next(context); + }); + app.UseRequestDecompression(); + app.Run(async context => + { + exception = await Record.ExceptionAsync(async () => + { + using var ms = new MemoryStream(); + await context.Request.Body.CopyToAsync(ms, context.RequestAborted); + }); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new ByteArrayContent(compressedBytes); + request.Content.Headers.ContentEncoding.Add(contentEncoding); + + // Act + await client.SendAsync(request); + + // Assert + var logMessages = sink.Writes.ToList(); + + AssertDecompressedWithLog(logMessages, contentEncoding.ToLowerInvariant()); + + Assert.NotNull(exception); + Assert.IsAssignableFrom(exception); + } + + [Fact] + public async Task Options_RegisterCustomDecompressionProvider() + { + // Arrange + var uncompressedBytes = GetUncompressedContent(); + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + var contentEncoding = "custom"; + + // Act + var (logMessages, decompressedBytes) = + await InvokeMiddleware( + compressedBytes, + new[] { contentEncoding }, + configure: (RequestDecompressionOptions options) => + { + options.DecompressionProviders.Add(contentEncoding, new CustomDecompressionProvider()); + }); + + // Assert + AssertDecompressedWithLog(logMessages, contentEncoding); + Assert.Equal(uncompressedBytes, decompressedBytes); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Endpoint_HasRequestSizeLimit_UsedForRequest(bool exceedsLimit) + { + // Arrange + long attributeSizeLimit = 10; + long featureSizeLimit = 5; + + var contentEncoding = "gzip"; + var uncompressedBytes = new byte[attributeSizeLimit + (exceedsLimit ? 1 : 0)]; + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + + var decompressedBytes = Array.Empty(); + Exception exception = null; + + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRequestDecompression(); + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.Use((context, next) => + { + context.Features.Set( + GetFakeEndpointFeature(attributeSizeLimit)); + context.Features.Set( + new FakeHttpMaxRequestBodySizeFeature(featureSizeLimit)); + + return next(context); + }); + app.UseRequestDecompression(); + app.Run(async context => + { + await using var ms = new MemoryStream(); + + exception = await Record.ExceptionAsync(async () => + { + await context.Request.Body.CopyToAsync(ms, context.RequestAborted); + decompressedBytes = ms.ToArray(); + }); + + decompressedBytes = ms.ToArray(); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new ByteArrayContent(compressedBytes); + request.Content.Headers.ContentEncoding.Add(contentEncoding); + + // Act + await client.SendAsync(request); + + // Assert + var logMessages = sink.Writes.ToList(); + AssertDecompressedWithLog(logMessages, contentEncoding); + + if (exceedsLimit) + { + Assert.NotNull(exception); + Assert.IsAssignableFrom(exception); + Assert.Equal("The maximum number of bytes have been read.", exception.Message); + } + else + { + Assert.Null(exception); + Assert.Equal(uncompressedBytes, decompressedBytes); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Feature_HasRequestSizeLimit_UsedForRequest(bool exceedsLimit) + { + // Arrange + long featureSizeLimit = 10; + + var contentEncoding = "gzip"; + var uncompressedBytes = new byte[featureSizeLimit + (exceedsLimit ? 1 : 0)]; + var compressedBytes = await GetGZipCompressedContent(uncompressedBytes); + + var decompressedBytes = Array.Empty(); + Exception exception = null; + + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRequestDecompression(); + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.Use((context, next) => + { + context.Features.Set( + new FakeHttpMaxRequestBodySizeFeature(featureSizeLimit)); + + return next(context); + }); + app.UseRequestDecompression(); + app.Run(async context => + { + await using var ms = new MemoryStream(); + + exception = await Record.ExceptionAsync(async () => + { + await context.Request.Body.CopyToAsync(ms, context.RequestAborted); + decompressedBytes = ms.ToArray(); + }); + + decompressedBytes = ms.ToArray(); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new ByteArrayContent(compressedBytes); + request.Content.Headers.ContentEncoding.Add(contentEncoding); + + // Act + await client.SendAsync(request); + + // Assert + var logMessages = sink.Writes.ToList(); + AssertDecompressedWithLog(logMessages, contentEncoding); + + if (exceedsLimit) + { + Assert.NotNull(exception); + Assert.IsAssignableFrom(exception); + Assert.Equal("The maximum number of bytes have been read.", exception.Message); + } + else + { + Assert.Null(exception); + Assert.Equal(uncompressedBytes, decompressedBytes); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Endpoint_DoesNotHaveSizeLimitMetadata(bool isCompressed) + { + // Arrange + var sink = new TestSink(); + var logger = new TestLogger( + new TestLoggerFactory(sink, enabled: true)); + IRequestDecompressionProvider provider = new FakeRequestDecompressionProvider(isCompressed); + + var middleware = new RequestDecompressionMiddleware( + c => + { + c.Response.StatusCode = StatusCodes.Status200OK; + return Task.CompletedTask; + }, + logger, + provider); + + var context = new DefaultHttpContext(); + + IEndpointFeature endpointFeature = new FakeEndpointFeature + { + Endpoint = new Endpoint( + requestDelegate: null, + metadata: new EndpointMetadataCollection(), + displayName: null) + }; + context.HttpContext.Features.Set(endpointFeature); + + long expectedRequestSizeLimit = 100; + IHttpMaxRequestBodySizeFeature maxRequestBodySizeFeature = + new FakeHttpMaxRequestBodySizeFeature(expectedRequestSizeLimit); + context.HttpContext.Features.Set(maxRequestBodySizeFeature); + + // Act + await middleware.Invoke(context); + + // Assert + var logMessages = sink.Writes.ToList(); + AssertLog(Assert.Single(logMessages), LogLevel.Debug, + $"The endpoint does not specify the {nameof(IRequestSizeLimitMetadata)}."); + + var actualRequestSizeLimit = maxRequestBodySizeFeature.MaxRequestBodySize; + Assert.Equal(expectedRequestSizeLimit, actualRequestSizeLimit); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Endpoint_DoesNotHaveBodySizeFeature(bool isCompressed) + { + // Arrange + var sink = new TestSink(); + var logger = new TestLogger( + new TestLoggerFactory(sink, enabled: true)); + IRequestDecompressionProvider provider = new FakeRequestDecompressionProvider(isCompressed); + + var middleware = new RequestDecompressionMiddleware( + c => + { + c.Response.StatusCode = StatusCodes.Status200OK; + return Task.CompletedTask; + }, + logger, + provider); + + var context = new DefaultHttpContext(); + + IEndpointFeature endpointFeature = GetFakeEndpointFeature(100); + context.HttpContext.Features.Set(endpointFeature); + + IHttpMaxRequestBodySizeFeature maxRequestBodySizeFeature = null; + context.HttpContext.Features.Set(maxRequestBodySizeFeature); + + // Act + await middleware.Invoke(context); + + // Assert + var logMessages = sink.Writes.ToList(); + AssertLog(Assert.Single(logMessages), LogLevel.Warning, + $"A request body size limit could not be applied. This server does not support the {nameof(IHttpMaxRequestBodySizeFeature)}."); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Endpoint_BodySizeFeatureIsReadOnly(bool isCompressed) + { + // Arrange + var sink = new TestSink(); + var logger = new TestLogger( + new TestLoggerFactory(sink, enabled: true)); + IRequestDecompressionProvider provider = new FakeRequestDecompressionProvider(isCompressed); + + var middleware = new RequestDecompressionMiddleware( + c => + { + c.Response.StatusCode = StatusCodes.Status200OK; + return Task.CompletedTask; + }, + logger, + provider); + + var context = new DefaultHttpContext(); + + IEndpointFeature endpointFeature = GetFakeEndpointFeature(100); + context.HttpContext.Features.Set(endpointFeature); + + long expectedRequestSizeLimit = 50; + IHttpMaxRequestBodySizeFeature maxRequestBodySizeFeature = + new FakeHttpMaxRequestBodySizeFeature(expectedRequestSizeLimit, isReadOnly: true); + context.HttpContext.Features.Set(maxRequestBodySizeFeature); + + // Act + await middleware.Invoke(context); + + // Assert + var logMessages = sink.Writes.ToList(); + AssertLog(Assert.Single(logMessages), LogLevel.Warning, + $"A request body size limit could not be applied. The {nameof(IHttpMaxRequestBodySizeFeature)} for the server is read-only."); + + var actualRequestSizeLimit = maxRequestBodySizeFeature.MaxRequestBodySize; + Assert.Equal(expectedRequestSizeLimit, actualRequestSizeLimit); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(false, true)] + public async Task Endpoint_HasBodySizeFeature_SetUsingSizeLimitMetadata(bool isCompressed, bool isRequestSizeLimitDisabled) + { + // Arrange + var sink = new TestSink(); + var logger = new TestLogger( + new TestLoggerFactory(sink, enabled: true)); + IRequestDecompressionProvider provider = new FakeRequestDecompressionProvider(isCompressed); + + var middleware = new RequestDecompressionMiddleware( + c => + { + c.Response.StatusCode = StatusCodes.Status200OK; + return Task.CompletedTask; + }, + logger, + provider); + + var context = new DefaultHttpContext(); + + long? expectedRequestSizeLimit = isRequestSizeLimitDisabled ? null : 100; + IEndpointFeature endpointFeature = GetFakeEndpointFeature(expectedRequestSizeLimit); + context.HttpContext.Features.Set(endpointFeature); + + IHttpMaxRequestBodySizeFeature maxRequestBodySizeFeature = + new FakeHttpMaxRequestBodySizeFeature(50); + context.HttpContext.Features.Set(maxRequestBodySizeFeature); + + // Act + await middleware.Invoke(context); + + // Assert + var logMessages = sink.Writes.ToList(); + + if (isRequestSizeLimitDisabled) + { + AssertLog(Assert.Single(logMessages), LogLevel.Debug, + "The maximum request body size as been disabled."); + } + else + { + AssertLog(Assert.Single(logMessages), LogLevel.Debug, + $"The maximum request body size has been set to {expectedRequestSizeLimit.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + var actualRequestSizeLimit = maxRequestBodySizeFeature.MaxRequestBodySize; + Assert.Equal(expectedRequestSizeLimit, actualRequestSizeLimit); + } + + [Fact] + public void Ctor_NullRequestDelegate_Throws() + { + // Arrange + RequestDelegate requestDelegate = null; + var logger = new TestLogger( + new TestLoggerFactory(new TestSink(), enabled: true)); + var provider = new FakeRequestDecompressionProvider(); + + // Act + Assert + Assert.Throws(() => + { + new RequestDecompressionMiddleware(requestDelegate, logger, provider); + }); + } + + [Fact] + public void Ctor_NullLogger_Throws() + { + // Arrange + static Task requestDelegate(HttpContext context) => Task.FromResult(context); + ILogger logger = null; + var provider = new FakeRequestDecompressionProvider(); + + // Act + Assert + Assert.Throws(() => + { + new RequestDecompressionMiddleware(requestDelegate, logger, provider); + }); + } + + [Fact] + public void Ctor_NullRequestDecompressionProvider_Throws() + { + // Arrange + static Task requestDelegate(HttpContext context) => Task.FromResult(context); + var logger = new TestLogger( + new TestLoggerFactory(new TestSink(), enabled: true)); + IRequestDecompressionProvider provider = null; + + // Act + Assert + Assert.Throws(() => + { + new RequestDecompressionMiddleware(requestDelegate, logger, provider); + }); + } + + private class FakeRequestDecompressionProvider : IRequestDecompressionProvider + { + private readonly bool _isCompressed; + + public FakeRequestDecompressionProvider(bool isCompressed = false) + { + _isCompressed = isCompressed; + } + +#nullable enable + public Stream? GetDecompressionStream(HttpContext context) + => _isCompressed + ? new MemoryStream() + : null; +#nullable disable + } + + private static void AssertLog(WriteContext log, LogLevel level, string message) + { + Assert.Equal(level, log.LogLevel); + Assert.Equal(message, log.State.ToString()); + } + + private static void AssertDecompressedWithLog(List logMessages, string encoding) + { + var logMessage = Assert.Single(logMessages); + AssertLog(logMessage, LogLevel.Debug, $"The request will be decompressed with '{encoding}'."); + } + + private static void AssertNoDecompressionProviderLog(List logMessages) + { + var logMessage = Assert.Single(logMessages); + AssertLog(logMessage, LogLevel.Debug, "No matching request decompression provider found."); + } + + private static async Task<(List, byte[])> InvokeMiddleware( + byte[] compressedContent, + string[] contentEncodings = null, + Action configure = null) + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + var outputContent = Array.Empty(); + + using var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRequestDecompression(configure ?? (_ => { })); + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.Use((context, next) => + { + context.Features.Set( + new FakeHttpMaxRequestBodySizeFeature()); + return next(context); + }); + app.UseRequestDecompression(); + app.Run(async context => + { + await using var ms = new MemoryStream(); + await context.Request.Body.CopyToAsync(ms, context.RequestAborted); + outputContent = ms.ToArray(); + }); + }); + }).Build(); + + await host.StartAsync(); + + var server = host.GetTestServer(); + var client = server.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new ByteArrayContent(compressedContent); + + if (contentEncodings != null) + { + foreach (var encoding in contentEncodings) + { + request.Content.Headers.ContentEncoding.Add(encoding); + } + } + + await client.SendAsync(request); + + return (sink.Writes.ToList(), outputContent); + } + private class CustomDecompressionProvider : IDecompressionProvider + { + public Stream GetDecompressionStream(Stream stream) + { + return new GZipStream(stream, CompressionMode.Decompress); + } + } + + private static FakeEndpointFeature GetFakeEndpointFeature(long? requestSizeLimit) + { + var requestSizeLimitMetadata = new FakeRequestSizeLimitMetadata + { + MaxRequestBodySize = requestSizeLimit + }; + + var endpointMetadata = + new EndpointMetadataCollection(new[] { requestSizeLimitMetadata }); + + return new FakeEndpointFeature + { + Endpoint = new Endpoint( + requestDelegate: null, + metadata: endpointMetadata, + displayName: null) + }; + } + + private class FakeEndpointFeature : IEndpointFeature + { + public Endpoint Endpoint { get; set; } + } + + private class FakeRequestSizeLimitMetadata : IRequestSizeLimitMetadata + { + public long? MaxRequestBodySize { get; set; } + } + + private class FakeHttpMaxRequestBodySizeFeature : IHttpMaxRequestBodySizeFeature + { + public FakeHttpMaxRequestBodySizeFeature( + long? maxRequestBodySize = null, + bool isReadOnly = false) + { + MaxRequestBodySize = maxRequestBodySize; + IsReadOnly = isReadOnly; + } + + public bool IsReadOnly { get; } + + public long? MaxRequestBodySize { get; set; } + } +} diff --git a/src/Middleware/RequestDecompression/test/RequestDecompressionOptionsTests.cs b/src/Middleware/RequestDecompression/test/RequestDecompressionOptionsTests.cs new file mode 100644 index 000000000000..eb05050b2dee --- /dev/null +++ b/src/Middleware/RequestDecompression/test/RequestDecompressionOptionsTests.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.RequestDecompression.Tests; + +public class RequestDecompressionOptionsTests +{ + [Fact] + public void Options_InitializedWithDefaultProviders() + { + // Arrange + var defaultProviderCount = 3; + + // Act + var options = new RequestDecompressionOptions(); + + // Assert + var providers = options.DecompressionProviders; + Assert.Equal(defaultProviderCount, providers.Count); + + var brotliProvider = Assert.Contains("br", providers); + Assert.IsType(brotliProvider); + + var deflateProvider = Assert.Contains("deflate", providers); + Assert.IsType(deflateProvider); + + var gzipProvider = Assert.Contains("gzip", providers); + Assert.IsType(gzipProvider); + } +} diff --git a/src/Middleware/RequestDecompression/test/RequestDecompressionServiceExtensionsTests.cs b/src/Middleware/RequestDecompression/test/RequestDecompressionServiceExtensionsTests.cs new file mode 100644 index 000000000000..00c4f9885f54 --- /dev/null +++ b/src/Middleware/RequestDecompression/test/RequestDecompressionServiceExtensionsTests.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.RequestDecompression.Tests; + +public class RequestDecompressionServiceExtensionsTests +{ + [Fact] + public void AddRequestDecompression_NullServiceCollection_Throws() + { + // Arrange + IServiceCollection serviceCollection = null; + var configureOptions = (RequestDecompressionOptions options) => { }; + + // Act + Assert + Assert.Throws(() => + { + serviceCollection.AddRequestDecompression(configureOptions); + }); + } + + [Fact] + public void AddRequestDecompression_NullConfigureOptions_Throws() + { + // Arrange + var serviceCollection = new ServiceCollection(); + Action configureOptions = null; + + // Act + Assert + Assert.Throws(() => + { + serviceCollection.AddRequestDecompression(configureOptions); + }); + } +} diff --git a/src/Middleware/RequestDecompression/test/SizeLimitedStreamTests.cs b/src/Middleware/RequestDecompression/test/SizeLimitedStreamTests.cs new file mode 100644 index 000000000000..a4e086abfbb9 --- /dev/null +++ b/src/Middleware/RequestDecompression/test/SizeLimitedStreamTests.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.RequestDecompression.Tests; + +public class SizeLimitedStreamTests +{ + [Fact] + public void Ctor_NullInnerStream_Throws() + { + // Arrange + Stream innerStream = null; + + // Act + Assert + Assert.Throws(() => + { + using var sizeLimitedStream = new SizeLimitedStream(innerStream, 1); + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ReadAsync_InnerStreamExceedsSizeLimit_Throws(bool exceedsLimit) + { + // Arrange + var sizeLimit = 10; + var bytes = new byte[sizeLimit + (exceedsLimit ? 1 : 0)]; + + using var innerStream = new MemoryStream(bytes); + using var sizeLimitedStream = new SizeLimitedStream(innerStream, sizeLimit); + + var buffer = new byte[bytes.Length]; + + // Act + var exception = await Record.ExceptionAsync(async () => + { + while (await sizeLimitedStream.ReadAsync(buffer) > 0) { } + }); + + // Assert + AssertStreamReadingException(exception, exceedsLimit); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Read_InnerStreamExceedsSizeLimit_Throws(bool exceedsLimit) + { + // Arrange + var sizeLimit = 10; + var bytes = new byte[sizeLimit + (exceedsLimit ? 1 : 0)]; + + using var innerStream = new MemoryStream(bytes); + using var sizeLimitedStream = new SizeLimitedStream(innerStream, sizeLimit); + + var buffer = new byte[bytes.Length]; + + // Act + var exception = Record.Exception(() => + { + while (sizeLimitedStream.Read(buffer, 0, buffer.Length) > 0) { } + }); + + // Assert + AssertStreamReadingException(exception, exceedsLimit); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void BeginRead_InnerStreamExceedsSizeLimit_Throws(bool exceedsLimit) + { + // Arrange + var sizeLimit = 10; + var bytes = new byte[sizeLimit + (exceedsLimit ? 1 : 0)]; + + using var innerStream = new MemoryStream(bytes); + using var sizeLimitedStream = new SizeLimitedStream(innerStream, sizeLimit); + + var buffer = new byte[bytes.Length]; + + // Act + var exception = Record.Exception(() => + { + var asyncResult = sizeLimitedStream.BeginRead(buffer, 0, buffer.Length, (o) => { }, null); + sizeLimitedStream.EndRead(asyncResult); + }); + + // Assert + AssertStreamReadingException(exception, exceedsLimit); + } + + private static void AssertStreamReadingException(Exception exception, bool exceedsLimit) + { + if (exceedsLimit) + { + Assert.NotNull(exception); + Assert.IsAssignableFrom(exception); + Assert.Equal("The maximum number of bytes have been read.", exception.Message); + } + else + { + Assert.Null(exception); + } + } +} diff --git a/src/Mvc/Mvc.Core/src/DisableRequestSizeLimitAttribute.cs b/src/Mvc/Mvc.Core/src/DisableRequestSizeLimitAttribute.cs index 456ccaf3fc30..112850901681 100644 --- a/src/Mvc/Mvc.Core/src/DisableRequestSizeLimitAttribute.cs +++ b/src/Mvc/Mvc.Core/src/DisableRequestSizeLimitAttribute.cs @@ -1,6 +1,7 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; @@ -9,8 +10,12 @@ namespace Microsoft.AspNetCore.Mvc; /// /// Disables the request body size limit. /// +/// +/// Disabling the request body size limit can be a security concern in regards to uncontrolled +/// resource consumption, particularly if the request body is being buffered. +/// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] -public class DisableRequestSizeLimitAttribute : Attribute, IFilterFactory, IOrderedFilter +public class DisableRequestSizeLimitAttribute : Attribute, IFilterFactory, IOrderedFilter, IRequestSizeLimitMetadata { /// /// Gets the order value for determining the order of execution of filters. Filters execute in @@ -39,4 +44,7 @@ public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) var filter = serviceProvider.GetRequiredService(); return filter; } + + /// + long? IRequestSizeLimitMetadata.MaxRequestBodySize => null; } diff --git a/src/Mvc/Mvc.Core/src/RequestSizeLimitAttribute.cs b/src/Mvc/Mvc.Core/src/RequestSizeLimitAttribute.cs index 15c69a613172..63b79fb23c70 100644 --- a/src/Mvc/Mvc.Core/src/RequestSizeLimitAttribute.cs +++ b/src/Mvc/Mvc.Core/src/RequestSizeLimitAttribute.cs @@ -1,6 +1,7 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; @@ -10,7 +11,7 @@ namespace Microsoft.AspNetCore.Mvc; /// Sets the request body size limit to the specified size. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] -public class RequestSizeLimitAttribute : Attribute, IFilterFactory, IOrderedFilter +public class RequestSizeLimitAttribute : Attribute, IFilterFactory, IOrderedFilter, IRequestSizeLimitMetadata { private readonly long _bytes; @@ -51,4 +52,7 @@ public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) filter.Bytes = _bytes; return filter; } + + /// + long? IRequestSizeLimitMetadata.MaxRequestBodySize => _bytes; }