using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Signing; using PrivaPub.Infrastructure.Statistics; using PrivaPub.Models.Statistics; using System.Threading.RateLimiting; namespace PrivaPub.Infrastructure { public class RateLimitOptions { public int AccountsPerMinute { get; set; } = 10;//sign-ups, sign-ins and recoveries per client address public int InboxBurst { get; set; } = 300;//deliveries a sending origin may make at once public int InboxPerTenSeconds { get; set; } = 50;//and the rate it earns them back public int UploadsBurst { get; set; } = 30;//uploads (media, profile pictures) a session may make at once public int UploadsPerMinute { get; set; } = 10;//and the rate it earns them back } public static class RateLimiting { public const string Accounts = "accounts"; public const string Inbox = "inbox"; public const string Uploads = "uploads"; static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService>().Value; public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service, IConfiguration configuration) => service.Configure(configuration.GetSection("RateLimits")).AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = (context, _) => { var http = context.HttpContext; var ledger = http.RequestServices.GetService(); var policy = http.GetEndpoint()?.Metadata.GetMetadata()?.PolicyName; if (policy == Inbox) ledger?.Record(new InteractionEvent { Channel = Interactions.Receive, Host = Interactions.HostOf(SenderOrigin(http.Request)), Status = StatusCodes.Status429TooManyRequests, Outcome = Interactions.Refused, Reason = "rate-limited", Inbox = http.Request.Path.Value?.EndsWith("/mouth", StringComparison.Ordinal) == true ? "personal" : "shared" }, hostClaimed: true); else ledger?.CountServer(ServerSections.Client, $"{policy ?? "unknown"}:429"); return ValueTask.CompletedTask; }; options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = Limits(context).AccountsPerMinute, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter( SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress, _ => new TokenBucketRateLimiterOptions { TokenLimit = Limits(context).InboxBurst, TokensPerPeriod = Limits(context).InboxPerTenSeconds, ReplenishmentPeriod = TimeSpan.FromSeconds(10), QueueLimit = 0 })); // per session: the limiter runs before authentication, so the credential sent stands for whoever sends it options.AddPolicy(Uploads, context => RateLimitPartition.GetTokenBucketLimiter( Credential(context.Request) ?? "anonymous:" + context.Connection.RemoteIpAddress, _ => new TokenBucketRateLimiterOptions { TokenLimit = Limits(context).UploadsBurst, TokensPerPeriod = Limits(context).UploadsPerMinute, ReplenishmentPeriod = TimeSpan.FromMinutes(1), QueueLimit = 0 })); }); // a hash of the credential, never the credential itself static string Credential(HttpRequest request) { var authorization = request.Headers.Authorization.ToString(); return string.IsNullOrEmpty(authorization) ? default : Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(authorization)))[..32]; } static string SenderOrigin(HttpRequest request) { var signature = request.Headers["Signature"].ToString(); return string.IsNullOrEmpty(signature) ? default : Origin.Of(HttpSignatures.Parse(signature)?.KeyId); } } }