diff --git a/AspNetCore.sln b/AspNetCore.sln index 0ba416a9b244..3ed16097b036 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1698,9 +1698,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ResultsOfTGenerator", "src\ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenApi", "OpenApi", "{2299CCD8-8F9C-4F2B-A633-9BF4DA81022B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Tests", "src\OpenApi\test\Microsoft.AspNetCore.OpenApi.Tests.csproj", "{3AEFB466-6310-4F3F-923F-9154224E3629}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OpenApi.Tests", "src\OpenApi\test\Microsoft.AspNetCore.OpenApi.Tests.csproj", "{3AEFB466-6310-4F3F-923F-9154224E3629}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi", "src\OpenApi\src\Microsoft.AspNetCore.OpenApi.csproj", "{EFC8EA45-572D-4D8D-A597-9045A2D8EC40}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OpenApi", "src\OpenApi\src\Microsoft.AspNetCore.OpenApi.csproj", "{EFC8EA45-572D-4D8D-A597-9045A2D8EC40}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.RateLimiting", "src\Middleware\RateLimiting\src\Microsoft.AspNetCore.RateLimiting.csproj", "{8EE73488-2B92-42BD-96C9-0DD65405C828}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.RateLimiting.Tests", "src\Middleware\RateLimiting\test\Microsoft.AspNetCore.RateLimiting.Tests.csproj", "{41FF4F96-98D2-4482-A2A7-4B179E80D285}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -10191,6 +10195,38 @@ Global {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|x64.Build.0 = Release|Any CPU {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|x86.ActiveCfg = Release|Any CPU {EFC8EA45-572D-4D8D-A597-9045A2D8EC40}.Release|x86.Build.0 = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|arm64.ActiveCfg = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|arm64.Build.0 = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|x64.ActiveCfg = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|x64.Build.0 = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|x86.ActiveCfg = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Debug|x86.Build.0 = Debug|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|Any CPU.Build.0 = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|arm64.ActiveCfg = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|arm64.Build.0 = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|x64.ActiveCfg = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|x64.Build.0 = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|x86.ActiveCfg = Release|Any CPU + {8EE73488-2B92-42BD-96C9-0DD65405C828}.Release|x86.Build.0 = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|arm64.ActiveCfg = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|arm64.Build.0 = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|x64.ActiveCfg = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|x64.Build.0 = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|x86.ActiveCfg = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Debug|x86.Build.0 = Debug|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|Any CPU.Build.0 = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|arm64.ActiveCfg = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|arm64.Build.0 = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|x64.ActiveCfg = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|x64.Build.0 = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|x86.ActiveCfg = Release|Any CPU + {41FF4F96-98D2-4482-A2A7-4B179E80D285}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 33d4d05ed215..d59faa1b0daa 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -89,6 +89,7 @@ + diff --git a/src/Middleware/Middleware.slnf b/src/Middleware/Middleware.slnf index ea55360ff484..2321ac94ea79 100644 --- a/src/Middleware/Middleware.slnf +++ b/src/Middleware/Middleware.slnf @@ -76,6 +76,8 @@ "src\\Middleware\\MiddlewareAnalysis\\samples\\MiddlewareAnalysisSample\\MiddlewareAnalysisSample.csproj", "src\\Middleware\\MiddlewareAnalysis\\src\\Microsoft.AspNetCore.MiddlewareAnalysis.csproj", "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\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\ResponseCaching\\samples\\ResponseCachingSample\\ResponseCachingSample.csproj", "src\\Middleware\\ResponseCaching\\src\\Microsoft.AspNetCore.ResponseCaching.csproj", @@ -115,4 +117,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/RateLimiting/src/Microsoft.AspNetCore.RateLimiting.csproj b/src/Middleware/RateLimiting/src/Microsoft.AspNetCore.RateLimiting.csproj new file mode 100644 index 000000000000..b8ba30375ac4 --- /dev/null +++ b/src/Middleware/RateLimiting/src/Microsoft.AspNetCore.RateLimiting.csproj @@ -0,0 +1,19 @@ + + + + ASP.NET Core middleware for enforcing rate limiting in an application + $(DefaultNetCoreTargetFramework) + true + aspnetcore + + + + + + + + + + + + diff --git a/src/Middleware/RateLimiting/src/NoLimiter.cs b/src/Middleware/RateLimiting/src/NoLimiter.cs new file mode 100644 index 000000000000..d3b61d1192b8 --- /dev/null +++ b/src/Middleware/RateLimiting/src/NoLimiter.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.RateLimiting; + +namespace Microsoft.AspNetCore.RateLimiting; +internal class NoLimiter : PartitionedRateLimiter +{ + public override int GetAvailablePermits(TResource resourceID) + { + return 1; + } + + protected override RateLimitLease AcquireCore(TResource resourceID, int permitCount) + { + return new NoLimiterLease(); + } + + protected override ValueTask WaitAsyncCore(TResource resourceID, int permitCount, CancellationToken cancellationToken) + { + return new ValueTask(new NoLimiterLease()); + } +} + +internal class NoLimiterLease : RateLimitLease +{ + public override bool IsAcquired => true; + + public override IEnumerable MetadataNames => new List(); + + public override bool TryGetMetadata(string metadataName, out object? metadata) + { + metadata = null; + return false; + } +} diff --git a/src/Middleware/RateLimiting/src/Properties/AssemblyInfo.cs b/src/Middleware/RateLimiting/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..2588fa961b28 --- /dev/null +++ b/src/Middleware/RateLimiting/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.RateLimiting.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Middleware/RateLimiting/src/PublicAPI.Shipped.txt b/src/Middleware/RateLimiting/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/Middleware/RateLimiting/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Middleware/RateLimiting/src/PublicAPI.Unshipped.txt b/src/Middleware/RateLimiting/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..b62cec89b1cc --- /dev/null +++ b/src/Middleware/RateLimiting/src/PublicAPI.Unshipped.txt @@ -0,0 +1,11 @@ +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions.DefaultRejectionStatusCode.get -> int +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions.DefaultRejectionStatusCode.set -> void +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions.Limiter.get -> System.Threading.RateLimiting.PartitionedRateLimiter! +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions.Limiter.set -> void +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions.OnRejected.get -> System.Func! +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions.OnRejected.set -> void +Microsoft.AspNetCore.RateLimiting.RateLimiterOptions.RateLimiterOptions() -> void +Microsoft.AspNetCore.RateLimiting.RateLimitingApplicationBuilderExtensions +static Microsoft.AspNetCore.RateLimiting.RateLimitingApplicationBuilderExtensions.UseRateLimiter(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! +static Microsoft.AspNetCore.RateLimiting.RateLimitingApplicationBuilderExtensions.UseRateLimiter(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, Microsoft.AspNetCore.RateLimiting.RateLimiterOptions! options) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! diff --git a/src/Middleware/RateLimiting/src/RateLimiterOptions.cs b/src/Middleware/RateLimiting/src/RateLimiterOptions.cs new file mode 100644 index 000000000000..bc1ab30b7a37 --- /dev/null +++ b/src/Middleware/RateLimiting/src/RateLimiterOptions.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. + +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.RateLimiting; + +/// +/// Specifies options for the rate limiting middleware. +/// +public sealed class RateLimiterOptions +{ + // TODO - Provide a default? + private PartitionedRateLimiter _limiter = new NoLimiter(); + private Func _onRejected = (context, lease) => + { + return Task.CompletedTask; + }; + + /// + /// Gets or sets the + /// + public PartitionedRateLimiter Limiter + { + get => _limiter; + set => _limiter = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Gets or sets a that handles requests rejected by this middleware. + /// + public Func OnRejected + { + get => _onRejected; + set => _onRejected = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Gets or sets the default status code to set on the response when a request is rejected. + /// Defaults to . + /// + /// + /// This status code will be set before is called, so any status code set by + /// will "win" over this default. + /// + public int DefaultRejectionStatusCode { get; set; } = StatusCodes.Status503ServiceUnavailable; +} diff --git a/src/Middleware/RateLimiting/src/RateLimitingApplicationBuilderExtensions.cs b/src/Middleware/RateLimiting/src/RateLimitingApplicationBuilderExtensions.cs new file mode 100644 index 000000000000..7cda2ab98c53 --- /dev/null +++ b/src/Middleware/RateLimiting/src/RateLimitingApplicationBuilderExtensions.cs @@ -0,0 +1,39 @@ +// 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; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.RateLimiting; + +/// +/// Extension methods for the RateLimiting middleware. +/// +public static class RateLimitingApplicationBuilderExtensions +{ + /// + /// Enables rate limiting for the application. + /// + /// + /// + public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.UseMiddleware(); + } + + /// + /// Enables rate limiting for the application. + /// + /// + /// + /// + public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app, RateLimiterOptions options) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + ArgumentNullException.ThrowIfNull(options, nameof(options)); + + return app.UseMiddleware(Options.Create(options)); + } +} diff --git a/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs b/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs new file mode 100644 index 000000000000..a97aa7c9ee83 --- /dev/null +++ b/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.RateLimiting; + +/// +/// Limits the rate of requests allowed in the application, based on limits set by a user-provided . +/// +internal sealed partial class RateLimitingMiddleware +{ + private readonly RequestDelegate _next; + private readonly Func _onRejected; + private readonly ILogger _logger; + private readonly PartitionedRateLimiter _limiter; + private readonly int _rejectionStatusCode; + + /// + /// Creates a new . + /// + /// The representing the next middleware in the pipeline. + /// The used for logging. + /// The options for the middleware. + public RateLimitingMiddleware(RequestDelegate next, ILogger logger, IOptions options) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _limiter = options.Value.Limiter; + _onRejected = options.Value.OnRejected; + _rejectionStatusCode = options.Value.DefaultRejectionStatusCode; + } + + // TODO - EventSource? + /// + /// Invokes the logic of the middleware. + /// + /// The . + /// A that completes when the request leaves. + public async Task Invoke(HttpContext context) + { + using var lease = await TryAcquireAsync(context); + if (lease.IsAcquired) + { + await _next(context); + } + else + { + RateLimiterLog.RequestRejectedLimitsExceeded(_logger); + // OnRejected "wins" over DefaultRejectionStatusCode - we set DefaultRejectionStatusCode first, + // then call OnRejected in case it wants to do any further modification of the status code. + context.Response.StatusCode = _rejectionStatusCode; + await _onRejected(context, lease); + } + } + + private ValueTask TryAcquireAsync(HttpContext context) + { + var lease = _limiter.Acquire(context); + if (lease.IsAcquired) + { + return ValueTask.FromResult(lease); + } + + return _limiter.WaitAsync(context, cancellationToken: context.RequestAborted); + } + + private static partial class RateLimiterLog + { + [LoggerMessage(1, LogLevel.Debug, "Rate limits exceeded, rejecting this request.", EventName = "RequestRejectedLimitsExceeded")] + internal static partial void RequestRejectedLimitsExceeded(ILogger logger); + } +} diff --git a/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj b/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj new file mode 100644 index 000000000000..9712d186644a --- /dev/null +++ b/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj @@ -0,0 +1,11 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + diff --git a/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs b/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs new file mode 100644 index 000000000000..37d6d5bc4c37 --- /dev/null +++ b/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs @@ -0,0 +1,53 @@ +// 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; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.RateLimiting; + +public class RateLimitingApplicationBuilderExtensionsTests : LoggedTest +{ + + [Fact] + public void UseRateLimiter_ThrowsOnNullAppBuilder() + { + Assert.Throws(() => RateLimitingApplicationBuilderExtensions.UseRateLimiter(null)); + } + + [Fact] + public void UseRateLimiter_ThrowsOnNullOptions() + { + var appBuilder = new ApplicationBuilder(new ServiceCollection().BuildServiceProvider()); + Assert.Throws(() => appBuilder.UseRateLimiter(null)); + } + + [Fact] + public void UseRateLimiter_RespectsOptions() + { + // These are the options that should get used + var options = new RateLimiterOptions(); + options.DefaultRejectionStatusCode = 429; + options.Limiter = new TestPartitionedRateLimiter(new TestRateLimiter(false)); + + // These should not get used + var services = new ServiceCollection(); + services.Configure(options => + { + options.Limiter = new TestPartitionedRateLimiter(new TestRateLimiter(false)); + options.DefaultRejectionStatusCode = 404; + }); + services.AddLogging(); + var serviceProvider = services.BuildServiceProvider(); + var appBuilder = new ApplicationBuilder(serviceProvider); + + // Act + appBuilder.UseRateLimiter(options); + var app = appBuilder.Build(); + var context = new DefaultHttpContext(); + app.Invoke(context); + Assert.Equal(429, context.Response.StatusCode); + } +} diff --git a/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs b/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs new file mode 100644 index 000000000000..903b225ed41b --- /dev/null +++ b/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs @@ -0,0 +1,122 @@ +// 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.AspNetCore.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.RateLimiting; + +public class RateLimitingMiddlewareTests : LoggedTest +{ + [Fact] + public void Ctor_ThrowsExceptionsWhenNullArgs() + { + var options = CreateOptionsAccessor(); + options.Value.Limiter = new TestPartitionedRateLimiter(); + + Assert.Throws(() => new RateLimitingMiddleware( + null, + new NullLoggerFactory().CreateLogger(), + options)); + + Assert.Throws(() => new RateLimitingMiddleware(c => + { + return Task.CompletedTask; + }, + null, + options)); + } + + [Fact] + public async Task RequestsCallNextIfAccepted() + { + var flag = false; + var options = CreateOptionsAccessor(); + options.Value.Limiter = new TestPartitionedRateLimiter(new TestRateLimiter(true)); + var middleware = new RateLimitingMiddleware(c => + { + flag = true; + return Task.CompletedTask; + }, + new NullLoggerFactory().CreateLogger(), + options); + + await middleware.Invoke(new DefaultHttpContext()); + Assert.True(flag); + } + + [Fact] + public async Task RequestRejected_CallsOnRejectedAndGives503() + { + var onRejectedInvoked = false; + var options = CreateOptionsAccessor(); + options.Value.Limiter = new TestPartitionedRateLimiter(new TestRateLimiter(false)); + options.Value.OnRejected = (httpContext, lease) => + { + onRejectedInvoked = true; + return Task.CompletedTask; + }; + + var middleware = new RateLimitingMiddleware(c => + { + return Task.CompletedTask; + }, + new NullLoggerFactory().CreateLogger(), + options); + + var context = new DefaultHttpContext(); + await middleware.Invoke(context).DefaultTimeout(); + Assert.True(onRejectedInvoked); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode); + } + + [Fact] + public async Task RequestRejected_WinsOverDefaultStatusCode() + { + var onRejectedInvoked = false; + var options = CreateOptionsAccessor(); + options.Value.Limiter = new TestPartitionedRateLimiter(new TestRateLimiter(false)); + options.Value.OnRejected = (httpContext, lease) => + { + onRejectedInvoked = true; + httpContext.Response.StatusCode = 429; + return Task.CompletedTask; + }; + + var middleware = new RateLimitingMiddleware(c => + { + return Task.CompletedTask; + }, + new NullLoggerFactory().CreateLogger(), + options); + + var context = new DefaultHttpContext(); + await middleware.Invoke(context).DefaultTimeout(); + Assert.True(onRejectedInvoked); + Assert.Equal(StatusCodes.Status429TooManyRequests, context.Response.StatusCode); + } + + [Fact] + public async Task RequestAborted_ThrowsTaskCanceledException() + { + var options = CreateOptionsAccessor(); + options.Value.Limiter = new TestPartitionedRateLimiter(new TestRateLimiter(false)); + + var middleware = new RateLimitingMiddleware(c => + { + return Task.CompletedTask; + }, + new NullLoggerFactory().CreateLogger(), + options); + + var context = new DefaultHttpContext(); + context.RequestAborted = new CancellationToken(true); + await Assert.ThrowsAsync(() => middleware.Invoke(context)).DefaultTimeout(); + } + + private IOptions CreateOptionsAccessor() => Options.Create(new RateLimiterOptions()); + +} diff --git a/src/Middleware/RateLimiting/test/RateLimitingOptionsTests.cs b/src/Middleware/RateLimiting/test/RateLimitingOptionsTests.cs new file mode 100644 index 000000000000..839a9d2da08a --- /dev/null +++ b/src/Middleware/RateLimiting/test/RateLimitingOptionsTests.cs @@ -0,0 +1,23 @@ +// 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.RateLimiting; + +public class RateLimitingOptionsTests +{ + [Fact] + public void ThrowsOnNullLimiter() + { + var options = new RateLimiterOptions(); + Assert.Throws(() => options.Limiter = null); + } + + [Fact] + public void ThrowsOnNullOnRejected() + { + var options = new RateLimiterOptions(); + Assert.Throws(() => options.OnRejected = null); + } +} diff --git a/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs b/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs new file mode 100644 index 000000000000..7e3017a95bce --- /dev/null +++ b/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.RateLimiting; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.RateLimiting; + +internal class TestPartitionedRateLimiter : PartitionedRateLimiter +{ + private List limiters = new List(); + + public TestPartitionedRateLimiter() { } + + public TestPartitionedRateLimiter(RateLimiter limiter) + { + limiters.Add(limiter); + } + + public void AddLimiter(RateLimiter limiter) + { + limiters.Add(limiter); + } + + public override int GetAvailablePermits(TResource resourceID) + { + throw new NotImplementedException(); + } + + protected override RateLimitLease AcquireCore(TResource resourceID, int permitCount) + { + if (permitCount != 1) + { + throw new ArgumentException("Tests only support 1 permit at a time"); + } + var leases = new List(); + foreach (var limiter in limiters) + { + var lease = limiter.Acquire(); + if (lease.IsAcquired) + { + leases.Add(lease); + } + else + { + foreach (var unusedLease in leases) + { + unusedLease.Dispose(); + } + return new TestRateLimitLease(false, null); + } + } + return new TestRateLimitLease(true, leases); + } + + protected override async ValueTask WaitAsyncCore(TResource resourceID, int permitCount, CancellationToken cancellationToken) + { + if (permitCount != 1) + { + throw new ArgumentException("Tests only support 1 permit at a time"); + } + var leases = new List(); + foreach (var limiter in limiters) + { + leases.Add(await limiter.WaitAsync().ConfigureAwait(false)); + } + foreach (var lease in leases) + { + if (!lease.IsAcquired) + { + foreach (var unusedLease in leases) + { + unusedLease.Dispose(); + } + return new TestRateLimitLease(false, null); + } + } + return new TestRateLimitLease(true, leases); + + } +} diff --git a/src/Middleware/RateLimiting/test/TestRateLimitLease.cs b/src/Middleware/RateLimiting/test/TestRateLimitLease.cs new file mode 100644 index 000000000000..afe31aff3171 --- /dev/null +++ b/src/Middleware/RateLimiting/test/TestRateLimitLease.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 System.Threading.RateLimiting; + +namespace Microsoft.AspNetCore.RateLimiting; + +internal class TestRateLimitLease : RateLimitLease +{ + internal List _leases; + + public TestRateLimitLease(bool isAcquired, List leases) + { + IsAcquired = isAcquired; + _leases = leases; + } + + public override bool IsAcquired { get; } + + public override IEnumerable MetadataNames => throw new NotImplementedException(); + + public override bool TryGetMetadata(string metadataName, out object metadata) + { + throw new NotImplementedException(); + } + + protected override void Dispose(bool disposing) + { + if (_leases != null) + { + foreach (var lease in _leases) + { + lease.Dispose(); + } + } + } +} diff --git a/src/Middleware/RateLimiting/test/TestRateLimiter.cs b/src/Middleware/RateLimiting/test/TestRateLimiter.cs new file mode 100644 index 000000000000..037b2fa5250b --- /dev/null +++ b/src/Middleware/RateLimiting/test/TestRateLimiter.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.RateLimiting; + +namespace Microsoft.AspNetCore.RateLimiting; +internal class TestRateLimiter : RateLimiter +{ + private readonly bool _alwaysAccept; + + public TestRateLimiter(bool alwaysAccept) + { + _alwaysAccept = alwaysAccept; + } + + public override TimeSpan? IdleDuration => throw new NotImplementedException(); + + public override int GetAvailablePermits() + { + throw new NotImplementedException(); + } + + protected override RateLimitLease AcquireCore(int permitCount) + { + return new TestRateLimitLease(_alwaysAccept, null); + } + + protected override ValueTask WaitAsyncCore(int permitCount, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(new TestRateLimitLease(_alwaysAccept, null)); + } +}