Files
SocialPub/PrivaPub/Infrastructure/RateLimiting.cs
T
thepraandClaude Opus 5.5 e4f9d0b61e An image is checked before it is decoded
An upload went straight to libvips: whatever loader recognised the bytes ran (an SVG sent as image/png was rasterised),
nothing bounded how many pixels it would decode to (a small PNG could decode to gigabytes, three times over), a GIF
was loaded frame by frame and never resized, and all of it ran inside the request with nothing limiting how many at
once. A GIF was typed gifv but stayed a .gif, which a gifv player can't play; its metadata was kept; colours lost
their ICC profile without being converted; HEIC was advertised but the bundled libvips can't decode it.

Now:
- only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (every other loader is blocked);
- the header alone says how big an image would decode, refused above Media:MaxPixels (40 MP) or MaxFrames;
- a still image is shrunk on load, turned by its orientation and brought into sRGB (thumbnail), then written without
  metadata, a profile picture the same way;
- an animated GIF becomes a looping silent H.264 mp4 typed gifv, as on Mastodon (PostMedia.Kind keeps it a gifv),
  and a remote GIF is an image;
- processing runs Media:Concurrency at a time, and uploads have their own rate limit per credential;
- HEIC and HEIF are no longer offered.

Tests: only the upload formats load, the header tells the size, an SVG posing as a PNG and an image too large are
refused before decoding, an animated GIF becomes a gifv and a still one an image, HEIC isn't advertised. The media
scenarios against the pasture (GoToSocial, Mastodon, Misskey, Akkoma, Pixelfed, Smithereen, Vernissage, Castopod,
PeerTube) pass: 329 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:48:44 +02:00

93 lines
4.0 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 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<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
}));
// 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);
}
}
}