using System.Net; using System.Threading.RateLimiting; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; namespace Api.SeaHavenIndustries.Infrastructure; /// /// Per-client limits on the anonymous password reset endpoints, plus the /// forwarded-header trust that makes "per client" mean the caller and not the proxy. /// public static class PasswordResetRateLimiting { public const string ForgetPasswordPolicy = "password-reset-request"; public const string VerificationCodePolicy = "password-reset-verify"; public const string ResetPasswordPolicy = "password-reset-confirm"; public const int PermitLimit = 10; public static readonly TimeSpan Window = TimeSpan.FromMinutes(15); public const string RejectedMessage = "Too many requests. Please try again later."; /// /// The API runs on Elastic Beanstalk behind an application load balancer and the /// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For /// is read right to left through loopback and private (VPC) hops only; the first /// public address is the client. Entries a caller writes further left are ignored. /// public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services) { services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor; options.ForwardLimit = null; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8)); options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8)); options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12)); options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16)); options.KnownProxies.Add(IPAddress.IPv6Loopback); }); services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = async (context, cancellationToken) => { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.HttpContext.Response.WriteAsJsonAsync( new Response { Status = "Error", Message = RejectedMessage }, cancellationToken); }; AddPerClientPolicy(options, ForgetPasswordPolicy); AddPerClientPolicy(options, VerificationCodePolicy); AddPerClientPolicy(options, ResetPasswordPolicy); }); return services; } public static string ClientPartitionKey(HttpContext context) { var address = context.Connection.RemoteIpAddress; if (address == null) return "unknown"; return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString(); } private static void AddPerClientPolicy(RateLimiterOptions options, string policyName) { options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter( ClientPartitionKey(context), _ => new FixedWindowRateLimiterOptions { PermitLimit = PermitLimit, Window = Window, QueueLimit = 0, AutoReplenishment = true })); } }