< Summary

Information
Class: ProjectTemplate.Web.Extensions.RateLimitingServiceExtensions
Assembly: ProjectTemplate.Web
File(s): /home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Web/Extensions/RateLimitingServiceExtensions.cs
Line coverage
97%
Covered lines: 206
Uncovered lines: 6
Coverable lines: 212
Total lines: 429
Line coverage: 97.1%
Branch coverage
84%
Covered branches: 44
Total branches: 52
Branch coverage: 84.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Web/Extensions/RateLimitingServiceExtensions.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Net.Sockets;
 4using System.Threading.RateLimiting;
 5using Microsoft.AspNetCore.Mvc;
 6using Microsoft.AspNetCore.RateLimiting;
 7using Microsoft.Extensions.Options;
 8using ProjectTemplate.Web.Constants;
 9using ProjectTemplate.Web.Diagnostics;
 10using ProjectTemplate.Web.ErrorHandling;
 11using ProjectTemplate.Web.Options;
 12
 13namespace ProjectTemplate.Web.Extensions;
 14
 15/// <summary>
 16/// Provides extension methods to register rate limiting services for the application.
 17/// </summary>
 18public static partial class RateLimitingServiceExtensions
 19{
 20    internal const string RejectionTitle = "Too Many Requests";
 21    internal const string RejectionDetail = "Too many requests were received. Please try again later.";
 22    internal const string RejectionProblemType = "https://www.rfc-editor.org/rfc/rfc6585#section-4";
 23
 24    private const int _ipv6AddressBitCount = 128;
 25
 26    /// <summary>
 27    /// Adds the application's predefined rate limiting policies to the service collection.
 28    /// </summary>
 29    /// <param name="services">The <see cref="IServiceCollection"/> to add the rate limiting services to.</param>
 30    /// <param name="configuration">The application configuration source.</param>
 31    /// <param name="environment">The current hosting environment.</param>
 32    /// <returns>The same <see cref="IServiceCollection"/> instance so calls can be chained.</returns>
 33    public static IServiceCollection AddApplicationRateLimiting(
 34        this IServiceCollection services,
 35        IConfiguration configuration,
 36        IHostEnvironment environment)
 37    {
 11638        RateLimitingFallbackWarningThrottle fallbackWarningThrottle = new(
 11639            TimeProvider.System,
 11640            RateLimitingFallbackWarningThrottle.DefaultInterval);
 41
 11642        services.Configure<ApplicationRateLimitingOptions>(options =>
 11643        {
 11644            ApplicationRateLimitingOptions defaultOptions = CreateDefaultOptions(environment);
 11645
 11646            options.Enabled = defaultOptions.Enabled;
 11647            options.UseGlobalLimiter = defaultOptions.UseGlobalLimiter;
 11648            options.UseSharedUnknownClientPartition = defaultOptions.UseSharedUnknownClientPartition;
 11649            options.UnknownClientPartitionKey = defaultOptions.UnknownClientPartitionKey;
 11650
 11651            options.GlobalFixedWindow.PermitLimit = defaultOptions.GlobalFixedWindow.PermitLimit;
 11652            options.GlobalFixedWindow.WindowSeconds = defaultOptions.GlobalFixedWindow.WindowSeconds;
 11653            options.GlobalFixedWindow.QueueLimit = defaultOptions.GlobalFixedWindow.QueueLimit;
 11654
 11655            options.FixedWindowPolicy.PermitLimit = defaultOptions.FixedWindowPolicy.PermitLimit;
 11656            options.FixedWindowPolicy.WindowSeconds = defaultOptions.FixedWindowPolicy.WindowSeconds;
 11657            options.FixedWindowPolicy.QueueLimit = defaultOptions.FixedWindowPolicy.QueueLimit;
 11658
 11659            options.ConcurrencyPolicy.PermitLimit = defaultOptions.ConcurrencyPolicy.PermitLimit;
 11660            options.ConcurrencyPolicy.QueueLimit = defaultOptions.ConcurrencyPolicy.QueueLimit;
 11661        });
 62
 11663        services
 11664            .AddOptions<ApplicationRateLimitingOptions>()
 11665            .Bind(configuration.GetSection(ApplicationRateLimitingOptions.SectionName))
 11666            .Validate(options => !string.IsNullOrWhiteSpace(options.UnknownClientPartitionKey),
 11667                "ProjectTemplate:RateLimiting:UnknownClientPartitionKey must not be empty.")
 11668            .Validate(options => options.IPv6PartitionPrefixLength is >= 1 and <= _ipv6AddressBitCount,
 11669                "ProjectTemplate:RateLimiting:IPv6PartitionPrefixLength must be between 1 and 128.")
 11670            .Validate(options => options.GlobalFixedWindow.PermitLimit > 0,
 11671                "ProjectTemplate:RateLimiting:GlobalFixedWindow:PermitLimit must be greater than zero.")
 11672            .Validate(options => options.GlobalFixedWindow.WindowSeconds > 0,
 11673                "ProjectTemplate:RateLimiting:GlobalFixedWindow:WindowSeconds must be greater than zero.")
 11674            .Validate(options => options.GlobalFixedWindow.QueueLimit >= 0,
 11675                "ProjectTemplate:RateLimiting:GlobalFixedWindow:QueueLimit must be zero or greater.")
 11676            .Validate(options => options.FixedWindowPolicy.PermitLimit > 0,
 11677                "ProjectTemplate:RateLimiting:FixedWindowPolicy:PermitLimit must be greater than zero.")
 11678            .Validate(options => options.FixedWindowPolicy.WindowSeconds > 0,
 11679                "ProjectTemplate:RateLimiting:FixedWindowPolicy:WindowSeconds must be greater than zero.")
 11680            .Validate(options => options.FixedWindowPolicy.QueueLimit >= 0,
 11681                "ProjectTemplate:RateLimiting:FixedWindowPolicy:QueueLimit must be zero or greater.")
 11682            .Validate(options => options.ConcurrencyPolicy.PermitLimit > 0,
 11683                "ProjectTemplate:RateLimiting:ConcurrencyPolicy:PermitLimit must be greater than zero.")
 11684            .Validate(options => options.ConcurrencyPolicy.QueueLimit >= 0,
 11685                "ProjectTemplate:RateLimiting:ConcurrencyPolicy:QueueLimit must be zero or greater.")
 11686            .ValidateOnStart();
 87
 11688        _ = services.AddRateLimiter();
 89
 11690        _ = services.AddOptions<RateLimiterOptions>()
 11691            .Configure<IOptions<ApplicationRateLimitingOptions>>((options, rateLimitingOptionsAccessor) =>
 11692            {
 11693                ApplicationRateLimitingOptions rateLimitingOptions = rateLimitingOptionsAccessor.Value;
 11694
 11695                options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
 11696
 11697                options.OnRejected = async (context, cancellationToken) =>
 11698                {
 11699                    HttpContext httpContext = context.HttpContext;
 116100
 116101                    TimeSpan? retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter
 116102                        ? retryAfterValue
 116103                        : null;
 116104
 116105                    ILogger logger = httpContext.RequestServices
 116106                        .GetRequiredService<ILoggerFactory>()
 116107                        .CreateLogger("Template.Web.RateLimiting");
 116108
 116109                    LogRejectedRequest(httpContext, logger, retryAfter);
 116110
 116111                    await WriteRejectionResponseAsync(httpContext, retryAfter, cancellationToken)
 116112                        .ConfigureAwait(false);
 116113                };
 116114
 116115                if (!rateLimitingOptions.Enabled)
 116116                {
 116117                    return;
 116118                }
 116119
 116120                if (rateLimitingOptions.UseGlobalLimiter)
 116121                {
 116122                    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
 116123                        RateLimitPartition.GetFixedWindowLimiter(
 116124                            partitionKey: GetClientPartitionKey(httpContext, rateLimitingOptions, fallbackWarningThrottl
 116125                            factory: _ => CreateFixedWindowRateLimiterOptions(rateLimitingOptions.GlobalFixedWindow)));
 116126                }
 116127
 116128                options.AddPolicy(ApplicationRateLimitingPolicyNames.Fixed, httpContext =>
 116129                    RateLimitPartition.GetFixedWindowLimiter(
 116130                        partitionKey: GetClientPartitionKey(httpContext, rateLimitingOptions, fallbackWarningThrottle),
 116131                        factory: _ => CreateFixedWindowRateLimiterOptions(rateLimitingOptions.FixedWindowPolicy)));
 116132
 116133                options.AddPolicy(ApplicationRateLimitingPolicyNames.Concurrency, httpContext =>
 116134                    RateLimitPartition.GetConcurrencyLimiter(
 116135                        partitionKey: GetConcurrencyPartitionKey(
 116136                            httpContext,
 116137                            rateLimitingOptions,
 116138                            CreateRateLimitingLogger(httpContext),
 116139                            fallbackWarningThrottle),
 116140                        factory: _ => CreateConcurrencyLimiterOptions(rateLimitingOptions.ConcurrencyPolicy)));
 116141            });
 142
 116143        return services;
 144    }
 145    /// <summary>
 146    /// Writes the rejection log entry for a rate-limited request, honoring the request-logging privacy options.
 147    /// </summary>
 148    /// <param name="httpContext">The rejected request's HTTP context.</param>
 149    /// <param name="logger">The logger that receives the entry.</param>
 150    /// <param name="retryAfter">The retry interval reported by the limiter lease, when available.</param>
 151    internal static void LogRejectedRequest(
 152        HttpContext httpContext,
 153        ILogger logger,
 154        TimeSpan? retryAfter)
 155    {
 7156        ArgumentNullException.ThrowIfNull(httpContext);
 7157        ArgumentNullException.ThrowIfNull(logger);
 158
 7159        LogRateLimitRejectedRequest(
 7160            logger,
 7161            httpContext.Request.Method,
 7162            httpContext.Request.Path.Value ?? string.Empty,
 7163            RequestLoggingPrivacy.GetLoggableRemoteIpAddress(httpContext),
 7164            httpContext.GetEndpoint()?.DisplayName,
 7165            retryAfter?.TotalSeconds,
 7166            httpContext.TraceIdentifier);
 7167    }
 168
 169    /// <summary>
 170    /// Writes the <c>429 Too Many Requests</c> response for a rate-limited request.
 171    /// </summary>
 172    /// <remarks>
 173    /// API-shaped requests, as classified by <see cref="ProblemDetailsRequestClassifier"/>, receive a Problem Details
 174    /// response through <see cref="IProblemDetailsService"/>, so the shared customization adds the trace, request, and
 175    /// correlation identifiers. Other requests receive a short plain-text body. The browser error page is intentionally
 176    /// not re-executed: rejections must stay cheap, and rendering a view for every rejected request would work against
 177    /// the protection the limiter provides.
 178    /// </remarks>
 179    /// <param name="httpContext">The rejected request's HTTP context.</param>
 180    /// <param name="retryAfter">The retry interval reported by the limiter lease, when available.</param>
 181    /// <param name="cancellationToken">A token that cancels writing the response.</param>
 182    /// <returns>A task that completes when the response has been written.</returns>
 183    internal static async Task WriteRejectionResponseAsync(
 184        HttpContext httpContext,
 185        TimeSpan? retryAfter,
 186        CancellationToken cancellationToken)
 187    {
 5188        ArgumentNullException.ThrowIfNull(httpContext);
 189
 5190        HttpResponse response = httpContext.Response;
 5191        response.StatusCode = StatusCodes.Status429TooManyRequests;
 192
 5193        if (retryAfter is TimeSpan retryAfterValue)
 194        {
 4195            response.Headers.RetryAfter = Math.Ceiling(retryAfterValue.TotalSeconds)
 4196                .ToString(CultureInfo.InvariantCulture);
 197        }
 198
 5199        if (ProblemDetailsRequestClassifier.ShouldWriteProblemDetails(httpContext))
 200        {
 1201            IProblemDetailsService? problemDetailsService = httpContext.RequestServices?
 1202                .GetService<IProblemDetailsService>();
 203
 1204            if (problemDetailsService is not null)
 205            {
 1206                ProblemDetailsContext problemDetailsContext = new()
 1207                {
 1208                    HttpContext = httpContext,
 1209                    ProblemDetails = new ProblemDetails
 1210                    {
 1211                        Status = StatusCodes.Status429TooManyRequests,
 1212                        Title = RejectionTitle,
 1213                        Type = RejectionProblemType,
 1214                        Detail = RejectionDetail
 1215                    }
 1216                };
 217
 1218                if (await problemDetailsService.TryWriteAsync(problemDetailsContext).ConfigureAwait(false))
 219                {
 1220                    return;
 221                }
 222            }
 223        }
 224
 4225        if (HttpMethods.IsHead(httpContext.Request.Method))
 226        {
 0227            return;
 228        }
 229
 4230        response.ContentType = "text/plain; charset=utf-8";
 4231        await response.WriteAsync(RejectionDetail, cancellationToken).ConfigureAwait(false);
 5232    }
 233
 234    private static ApplicationRateLimitingOptions CreateDefaultOptions(IHostEnvironment environment)
 235    {
 202236        ApplicationRateLimitingOptions options = new();
 237
 202238        if (environment.IsDevelopment())
 239        {
 0240            options.GlobalFixedWindow.PermitLimit = 300;
 0241            options.GlobalFixedWindow.WindowSeconds = 60;
 242
 0243            options.FixedWindowPolicy.PermitLimit = 120;
 0244            options.FixedWindowPolicy.WindowSeconds = 60;
 245
 0246            options.ConcurrencyPolicy.PermitLimit = 20;
 247        }
 248
 202249        return options;
 250    }
 251
 252    private static FixedWindowRateLimiterOptions CreateFixedWindowRateLimiterOptions(
 253        FixedWindowRateLimitingOptions options)
 254    {
 84255        return new FixedWindowRateLimiterOptions
 84256        {
 84257            AutoReplenishment = true,
 84258            PermitLimit = EnsureAtLeast(options.PermitLimit, minimum: 1),
 84259            Window = TimeSpan.FromSeconds(EnsureAtLeast(options.WindowSeconds, minimum: 1)),
 84260            QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
 84261            QueueLimit = EnsureAtLeast(options.QueueLimit, minimum: 0)
 84262        };
 263    }
 264
 265    private static ConcurrencyLimiterOptions CreateConcurrencyLimiterOptions(
 266        ConcurrencyRateLimitingOptions options)
 267    {
 1268        return new ConcurrencyLimiterOptions
 1269        {
 1270            PermitLimit = EnsureAtLeast(options.PermitLimit, minimum: 1),
 1271            QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
 1272            QueueLimit = EnsureAtLeast(options.QueueLimit, minimum: 0)
 1273        };
 274    }
 275
 276    private static string GetClientPartitionKey(
 277        HttpContext httpContext,
 278        ApplicationRateLimitingOptions options,
 279        RateLimitingFallbackWarningThrottle fallbackWarningThrottle)
 280    {
 115281        return GetClientPartitionKey(
 115282            httpContext,
 115283            options,
 115284            CreateRateLimitingLogger(httpContext),
 115285            fallbackWarningThrottle);
 286    }
 287
 288    private static ILogger CreateRateLimitingLogger(HttpContext httpContext)
 289    {
 118290        return httpContext.RequestServices
 118291            .GetRequiredService<ILoggerFactory>()
 118292            .CreateLogger("Template.Web.RateLimiting");
 293    }
 294
 295    internal static string GetClientPartitionKey(
 296        HttpContext httpContext,
 297        ApplicationRateLimitingOptions options,
 298        ILogger logger,
 299        RateLimitingFallbackWarningThrottle? fallbackWarningThrottle = null)
 300    {
 128301        IPAddress? remoteIpAddress = httpContext.Connection.RemoteIpAddress;
 302
 128303        if (remoteIpAddress is not null)
 304        {
 4305            return GetAddressPartitionKey(remoteIpAddress, options.IPv6PartitionPrefixLength);
 306        }
 307
 124308        string fallbackPartitionKey = string.IsNullOrWhiteSpace(options.UnknownClientPartitionKey)
 124309            ? "unknown-client"
 124310            : options.UnknownClientPartitionKey.Trim();
 124311        string fallbackMode = options.UseSharedUnknownClientPartition ? "Shared" : "PerRequest";
 124312        string fallbackDiscriminator = string.IsNullOrWhiteSpace(httpContext.TraceIdentifier)
 124313            ? Guid.NewGuid().ToString("N")
 124314            : httpContext.TraceIdentifier;
 315
 124316        if (!options.UseSharedUnknownClientPartition)
 317        {
 103318            fallbackPartitionKey = $"{fallbackPartitionKey}:{fallbackDiscriminator}";
 319        }
 320
 124321        long suppressedWarningCount = 0;
 322
 124323        if (fallbackWarningThrottle is null ||
 124324            fallbackWarningThrottle.TryAcquire(out suppressedWarningCount))
 325        {
 83326            LogRateLimitingClientPartitionFallback(
 83327                logger,
 83328                fallbackMode,
 83329                fallbackPartitionKey,
 83330                fallbackDiscriminator,
 83331                suppressedWarningCount);
 332        }
 333
 124334        return fallbackPartitionKey;
 335    }
 336
 337    /// <summary>
 338    /// Returns the rate limiting partition key for a client address.
 339    /// </summary>
 340    /// <remarks>
 341    /// IPv4-mapped IPv6 addresses are converted to IPv4 first, because a dual-stack listener reports IPv4 clients in
 342    /// that form and masking them as IPv6 would place every IPv4 client in one partition. Other IPv6 addresses are
 343    /// reduced to their network prefix so a client cannot bypass the limiter by rotating addresses inside it.
 344    /// </remarks>
 345    /// <param name="address">The client address.</param>
 346    /// <param name="ipv6PrefixLength">The IPv6 prefix length that identifies one client.</param>
 347    /// <returns>The partition key.</returns>
 348    internal static string GetAddressPartitionKey(IPAddress address, int ipv6PrefixLength)
 349    {
 12350        ArgumentNullException.ThrowIfNull(address);
 351
 12352        IPAddress normalizedAddress = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
 353
 12354        if (normalizedAddress.AddressFamily != AddressFamily.InterNetworkV6
 12355            || ipv6PrefixLength >= _ipv6AddressBitCount)
 356        {
 6357            return normalizedAddress.ToString();
 358        }
 359
 6360        int prefixLength = Math.Max(ipv6PrefixLength, 1);
 6361        byte[] addressBytes = normalizedAddress.GetAddressBytes();
 788362        for (int bitIndex = prefixLength; bitIndex < _ipv6AddressBitCount; bitIndex++)
 363        {
 388364            addressBytes[bitIndex / 8] &= (byte)~(0x80 >> (bitIndex % 8));
 365        }
 366
 6367        return string.Create(CultureInfo.InvariantCulture, $"{new IPAddress(addressBytes)}/{prefixLength}");
 368    }
 369
 370    /// <summary>
 371    /// Returns the partition key used by the named concurrency policy.
 372    /// </summary>
 373    /// <param name="httpContext">The current HTTP context.</param>
 374    /// <param name="options">The rate limiting options.</param>
 375    /// <param name="logger">The logger used when the client address falls back to an unknown-client partition.</param>
 376    /// <param name="fallbackWarningThrottle">An optional throttle for fallback warnings.</param>
 377    /// <returns>The endpoint key, combined with the client key when concurrency is partitioned by client.</returns>
 378    internal static string GetConcurrencyPartitionKey(
 379        HttpContext httpContext,
 380        ApplicationRateLimitingOptions options,
 381        ILogger logger,
 382        RateLimitingFallbackWarningThrottle? fallbackWarningThrottle = null)
 383    {
 5384        ArgumentNullException.ThrowIfNull(httpContext);
 5385        ArgumentNullException.ThrowIfNull(options);
 386
 5387        string endpointPartitionKey = GetEndpointPartitionKey(httpContext);
 388
 5389        return options.ConcurrencyPolicy.PartitionByClient
 5390            ? $"{endpointPartitionKey}|{GetClientPartitionKey(httpContext, options, logger, fallbackWarningThrottle)}"
 5391            : endpointPartitionKey;
 392    }
 393
 394    private static string GetEndpointPartitionKey(HttpContext httpContext)
 395    {
 5396        return httpContext.GetEndpoint()?.DisplayName
 5397            ?? httpContext.Request.Path.Value
 5398            ?? "unknown-endpoint";
 399    }
 400
 401    private static int EnsureAtLeast(int value, int minimum)
 402    {
 254403        return value < minimum ? minimum : value;
 404    }
 405
 406    [LoggerMessage(
 407        EventId = ApplicationLogEventIds.RateLimitRejectedRequest,
 408        Level = LogLevel.Warning,
 409        Message = "Rate limit rejected request. Method: {Method}; Path: {Path}; RemoteIpAddress: {RemoteIpAddress}; Endp
 410    private static partial void LogRateLimitRejectedRequest(
 411        ILogger logger,
 412        string method,
 413        string path,
 414        string? remoteIpAddress,
 415        string? endpoint,
 416        double? retryAfterSeconds,
 417        string traceIdentifier);
 418
 419    [LoggerMessage(
 420        EventId = ApplicationLogEventIds.RateLimitClientPartitionFallback,
 421        Level = LogLevel.Warning,
 422        Message = "Rate limiting used fallback client partition because RemoteIpAddress was unavailable. FallbackMode: {
 423    private static partial void LogRateLimitingClientPartitionFallback(
 424        ILogger logger,
 425        string fallbackMode,
 426        string partitionKey,
 427        string traceIdentifier,
 428        long suppressedWarningCount);
 429}