Anyone could mint signed proxy URLs (a remote account changes its icon, an anonymous lookup returns the URL), and each anonymous request held up to 40 MB in memory; the cache grew without bound between hourly trims; two clients asking for the same new file downloaded it twice and wrote over each other in place, so a reader could get half a file with a 7-day cache header; a file over the limit was downloaded twice on every request; a failed fetch, a 404, was cached by browsers for a week; cached media of a server suspended later were still served, and RejectMedia skipped avatars, emoji, covers, video variants, link cards and remote edits; /clientapi/group/members returned remote pictures raw. Now a download is shared by everyone asking at once, streamed into a .part file and renamed into place (FederationHttp.DownloadMedia copies bounded, never into memory), at most eight at a time; a file too big to cache is remembered for an hour and only streamed, a failure for five minutes; the cache's size is counted as it grows and trimmed as soon as it passes the cap; a cached file is opened before it is answered; browsers may cache only a success; nothing of a suspended server, or of one whose media are rejected, is proxied (everything remote a client sees goes through the proxy, so that covers every kind), and blocking one purges its cache; the proxy has its own rate limit per client address; group members' pictures are proxied; the proxy's key is loaded once, the oldest if two were made. This changes what PrivaPub serves its clients, not what it sends to other servers. Tests: clients asking at once share one download, a failure isn't cached by browsers, an over-limit file is fetched three times for two requests instead of four, a blocked server's media are refused and its cache purged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
105 lines
4.6 KiB
C#
105 lines
4.6 KiB
C#
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 int ProxyBurst { get; set; } = 1200;//remote media a client address may ask for at once (a timeline is many pictures)
|
|
public int ProxyPerMinute { get; set; } = 600;//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";
|
|
public const string Proxy = "proxy";
|
|
|
|
static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService<IOptions<RateLimitOptions>>().Value;
|
|
|
|
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service, IConfiguration configuration) =>
|
|
service.Configure<RateLimitOptions>(configuration.GetSection("RateLimits")).AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.OnRejected = (context, _) =>
|
|
{
|
|
var http = context.HttpContext;
|
|
var ledger = http.RequestServices.GetService<IInteractionLedger>();
|
|
var policy = http.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.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
|
|
}));
|
|
options.AddPolicy(Proxy, context => RateLimitPartition.GetTokenBucketLimiter(
|
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
_ => new TokenBucketRateLimiterOptions
|
|
{
|
|
TokenLimit = Limits(context).ProxyBurst,
|
|
TokensPerPeriod = Limits(context).ProxyPerMinute,
|
|
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
|
|
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);
|
|
}
|
|
}
|
|
}
|