feat: use a FixedWindowRateLimiter keyed by IP to rate limit emails

we don't talk about the sent_emails table :)
This commit is contained in:
sam 2024-12-11 21:17:46 +01:00
parent 1ce4f9d278
commit 51e335f090
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
8 changed files with 75 additions and 87 deletions

View file

@ -36,6 +36,7 @@ public class EmailAuthController(
DatabaseContext db, DatabaseContext db,
AuthService authService, AuthService authService,
MailService mailService, MailService mailService,
EmailRateLimiter rateLimiter,
KeyCacheService keyCacheService, KeyCacheService keyCacheService,
UserRendererService userRenderer, UserRendererService userRenderer,
IClock clock, IClock clock,
@ -68,6 +69,9 @@ public class EmailAuthController(
return NoContent(); return NoContent();
} }
if (IsRateLimited())
return NoContent();
mailService.QueueAccountCreationEmail(req.Email, state); mailService.QueueAccountCreationEmail(req.Email, state);
return NoContent(); return NoContent();
} }
@ -221,6 +225,9 @@ public class EmailAuthController(
return NoContent(); return NoContent();
} }
if (IsRateLimited())
return NoContent();
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username); mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
return NoContent(); return NoContent();
} }
@ -274,4 +281,34 @@ public class EmailAuthController(
if (!config.EmailAuth.Enabled) if (!config.EmailAuth.Enabled)
throw new ApiError.BadRequest("Email authentication is not enabled on this instance."); throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
} }
/// <summary>
/// Checks whether the context's IP address is rate limited from dispatching emails.
/// </summary>
private bool IsRateLimited()
{
if (HttpContext.Connection.RemoteIpAddress == null)
{
_logger.Information(
"No remote IP address in HTTP context for email-related request, ignoring as we can't rate limit it"
);
return true;
}
if (
!rateLimiter.IsLimited(
HttpContext.Connection.RemoteIpAddress.ToString(),
out Duration retryAfter
)
)
{
return false;
}
_logger.Information(
"IP address cannot send email until {RetryAfter}, ignoring",
retryAfter
);
return true;
}
} }

View file

@ -66,7 +66,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
public DbSet<Application> Applications { get; init; } = null!; public DbSet<Application> Applications { get; init; } = null!;
public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!; public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!;
public DbSet<DataExport> DataExports { get; init; } = null!; public DbSet<DataExport> DataExports { get; init; } = null!;
public DbSet<SentEmail> SentEmails { get; init; } = null!;
public DbSet<PrideFlag> PrideFlags { get; init; } = null!; public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
public DbSet<UserFlag> UserFlags { get; init; } = null!; public DbSet<UserFlag> UserFlags { get; init; } = null!;
@ -86,7 +85,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique(); modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
modelBuilder.Entity<SentEmail>().HasIndex(e => new { e.Email, e.SentAt });
// Two indexes on auth_methods, one for fediverse auth and one for all other types. // Two indexes on auth_methods, one for fediverse auth and one for all other types.
modelBuilder modelBuilder

View file

@ -302,33 +302,6 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("pride_flags", (string)null); b.ToTable("pride_flags", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.SentEmail", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<Instant>("SentAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("sent_at");
b.HasKey("Id")
.HasName("pk_sent_emails");
b.HasIndex("Email", "SentAt")
.HasDatabaseName("ix_sent_emails_email_sent_at");
b.ToTable("sent_emails", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")

View file

@ -1,13 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class SentEmail
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; init; }
public required string Email { get; init; }
public required Instant SentAt { get; init; }
}

View file

@ -110,6 +110,7 @@ public static class WebApplicationExtensions
.AddSingleton<IClock>(SystemClock.Instance) .AddSingleton<IClock>(SystemClock.Instance)
.AddSnowflakeGenerator() .AddSnowflakeGenerator()
.AddSingleton<MailService>() .AddSingleton<MailService>()
.AddSingleton<EmailRateLimiter>()
.AddScoped<UserRendererService>() .AddScoped<UserRendererService>()
.AddScoped<MemberRendererService>() .AddScoped<MemberRendererService>()
.AddScoped<AuthService>() .AddScoped<AuthService>()

View file

@ -33,9 +33,6 @@ public class DataCleanupService(
public async Task InvokeAsync(CancellationToken ct = default) public async Task InvokeAsync(CancellationToken ct = default)
{ {
_logger.Debug("Cleaning up sent email cache");
await CleanEmailsAsync(ct);
_logger.Debug("Cleaning up expired users"); _logger.Debug("Cleaning up expired users");
await CleanUsersAsync(ct); await CleanUsersAsync(ct);
@ -43,14 +40,6 @@ public class DataCleanupService(
await CleanExportsAsync(ct); await CleanExportsAsync(ct);
} }
private async Task CleanEmailsAsync(CancellationToken ct = default)
{
Instant expiry = clock.GetCurrentInstant() - Duration.FromHours(2);
int count = await db.SentEmails.Where(e => e.SentAt < expiry).ExecuteDeleteAsync(ct);
if (count != 0)
_logger.Information("Deleted {Count} entries from the sent email cache", expiry);
}
private async Task CleanUsersAsync(CancellationToken ct = default) private async Task CleanUsersAsync(CancellationToken ct = default)
{ {
Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter;

View file

@ -0,0 +1,36 @@
using System.Collections.Concurrent;
using System.Threading.RateLimiting;
using NodaTime;
using NodaTime.Extensions;
namespace Foxnouns.Backend.Services;
public class EmailRateLimiter
{
private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new();
private readonly FixedWindowRateLimiterOptions _limiterOptions =
new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 };
private RateLimiter GetLimiter(string bucket) =>
_limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions));
public bool IsLimited(string bucket, out Duration retryAfter)
{
RateLimiter limiter = GetLimiter(bucket);
RateLimitLease lease = limiter.AttemptAcquire();
if (!lease.IsAcquired)
{
retryAfter = lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan timeSpan)
? timeSpan.ToDuration()
: default;
}
else
{
retryAfter = Duration.Zero;
}
return !lease.IsAcquired;
}
}

View file

@ -15,22 +15,11 @@
using Coravel.Mailer.Mail; using Coravel.Mailer.Mail;
using Coravel.Mailer.Mail.Interfaces; using Coravel.Mailer.Mail.Interfaces;
using Coravel.Queuing.Interfaces; using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Mailables; using Foxnouns.Backend.Mailables;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services;
public class MailService( public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config)
ILogger logger,
IMailer mailer,
IQueue queue,
IClock clock,
Config config,
IServiceProvider serviceProvider
)
{ {
private readonly ILogger _logger = logger.ForContext<MailService>(); private readonly ILogger _logger = logger.ForContext<MailService>();
@ -78,29 +67,7 @@ public class MailService(
{ {
try try
{ {
// ReSharper disable SuggestVarOrType_SimpleTypes
await using var scope = serviceProvider.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
// ReSharper restore SuggestVarOrType_SimpleTypes
Instant now = clock.GetCurrentInstant();
int count = await db.SentEmails.CountAsync(e =>
e.Email == to && e.SentAt > (now - Duration.FromHours(1))
);
if (count >= 2)
{
_logger.Information(
"Have already sent 2 or more emails to {ToAddress} in the past hour, not sending new email",
to
);
return;
}
await mailer.SendAsync(mailable); await mailer.SendAsync(mailable);
db.SentEmails.Add(new SentEmail { Email = to, SentAt = now });
await db.SaveChangesAsync();
} }
catch (Exception exc) catch (Exception exc)
{ {