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:
		
							parent
							
								
									1ce4f9d278
								
							
						
					
					
						commit
						51e335f090
					
				
					 8 changed files with 75 additions and 87 deletions
				
			
		|  | @ -36,6 +36,7 @@ public class EmailAuthController( | |||
|     DatabaseContext db, | ||||
|     AuthService authService, | ||||
|     MailService mailService, | ||||
|     EmailRateLimiter rateLimiter, | ||||
|     KeyCacheService keyCacheService, | ||||
|     UserRendererService userRenderer, | ||||
|     IClock clock, | ||||
|  | @ -68,6 +69,9 @@ public class EmailAuthController( | |||
|             return NoContent(); | ||||
|         } | ||||
| 
 | ||||
|         if (IsRateLimited()) | ||||
|             return NoContent(); | ||||
| 
 | ||||
|         mailService.QueueAccountCreationEmail(req.Email, state); | ||||
|         return NoContent(); | ||||
|     } | ||||
|  | @ -221,6 +225,9 @@ public class EmailAuthController( | |||
|             return NoContent(); | ||||
|         } | ||||
| 
 | ||||
|         if (IsRateLimited()) | ||||
|             return NoContent(); | ||||
| 
 | ||||
|         mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username); | ||||
|         return NoContent(); | ||||
|     } | ||||
|  | @ -274,4 +281,34 @@ public class EmailAuthController( | |||
|         if (!config.EmailAuth.Enabled) | ||||
|             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; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -66,7 +66,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|     public DbSet<Application> Applications { get; init; } = null!; | ||||
|     public DbSet<TemporaryKey> TemporaryKeys { 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<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<TemporaryKey>().HasIndex(k => k.Key).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. | ||||
|         modelBuilder | ||||
|  |  | |||
|  | @ -302,33 +302,6 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                     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 => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|  |  | |||
|  | @ -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; } | ||||
| } | ||||
|  | @ -110,6 +110,7 @@ public static class WebApplicationExtensions | |||
|                     .AddSingleton<IClock>(SystemClock.Instance) | ||||
|                     .AddSnowflakeGenerator() | ||||
|                     .AddSingleton<MailService>() | ||||
|                     .AddSingleton<EmailRateLimiter>() | ||||
|                     .AddScoped<UserRendererService>() | ||||
|                     .AddScoped<MemberRendererService>() | ||||
|                     .AddScoped<AuthService>() | ||||
|  |  | |||
|  | @ -33,9 +33,6 @@ public class DataCleanupService( | |||
| 
 | ||||
|     public async Task InvokeAsync(CancellationToken ct = default) | ||||
|     { | ||||
|         _logger.Debug("Cleaning up sent email cache"); | ||||
|         await CleanEmailsAsync(ct); | ||||
| 
 | ||||
|         _logger.Debug("Cleaning up expired users"); | ||||
|         await CleanUsersAsync(ct); | ||||
| 
 | ||||
|  | @ -43,14 +40,6 @@ public class DataCleanupService( | |||
|         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) | ||||
|     { | ||||
|         Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; | ||||
|  |  | |||
							
								
								
									
										36
									
								
								Foxnouns.Backend/Services/EmailRateLimiter.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								Foxnouns.Backend/Services/EmailRateLimiter.cs
									
										
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
|  | @ -15,22 +15,11 @@ | |||
| using Coravel.Mailer.Mail; | ||||
| using Coravel.Mailer.Mail.Interfaces; | ||||
| using Coravel.Queuing.Interfaces; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Mailables; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Services; | ||||
| 
 | ||||
| public class MailService( | ||||
|     ILogger logger, | ||||
|     IMailer mailer, | ||||
|     IQueue queue, | ||||
|     IClock clock, | ||||
|     Config config, | ||||
|     IServiceProvider serviceProvider | ||||
| ) | ||||
| public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<MailService>(); | ||||
| 
 | ||||
|  | @ -78,29 +67,7 @@ public class MailService( | |||
|     { | ||||
|         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); | ||||
| 
 | ||||
|             db.SentEmails.Add(new SentEmail { Email = to, SentAt = now }); | ||||
|             await db.SaveChangesAsync(); | ||||
|         } | ||||
|         catch (Exception exc) | ||||
|         { | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue