feat: Implement spam protection with configurable limits and auto-mute escalation

This commit is contained in:
HueByte
2026-07-17 19:47:57 +02:00
parent 3281064720
commit 53d0d326cb
9 changed files with 556 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
namespace EchoHub.Server.Config;
/// <summary>
/// Anti-spam thresholds, bound from the "Spam" config section. Defaults are lenient enough
/// that a fast typist never trips them; Mods and above are always exempt.
/// </summary>
public sealed class SpamOptions
{
/// <summary>Master switch for all spam protection.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Max messages a user may send per <see cref="WindowSeconds"/> window.</summary>
public int MaxMessagesPerWindow { get; set; } = 8;
public int WindowSeconds { get; set; } = 5;
/// <summary>
/// How many identical messages in a row are tolerated; the next one is rejected.
/// (End-to-end encrypted rooms are naturally exempt — identical plaintext produces
/// different ciphertext per message, so repeats never look identical to the server.)
/// </summary>
public int MaxDuplicateMessages { get; set; } = 3;
/// <summary>
/// Auto-mute duration once a user accumulates <see cref="ViolationThreshold"/> rejected
/// sends within <see cref="ViolationWindowMinutes"/>. 0 disables auto-mute (rejections
/// still apply). The mute uses the normal timed-mute machinery, so moderators see it
/// and <c>MuteExpirationService</c> lifts it.
/// </summary>
public int AutoMuteMinutes { get; set; } = 5;
public int ViolationThreshold { get; set; } = 5;
public int ViolationWindowMinutes { get; set; } = 5;
/// <summary>
/// Max *first-time* channel joins per <see cref="JoinWindowSeconds"/> window. Joins of
/// channels the user already belongs to (reconnect/auto-join) never count, but a brand-new
/// user's first connect joins every public channel at once — keep this above your public
/// channel count.
/// </summary>
public int MaxJoinsPerWindow { get; set; } = 25;
public int JoinWindowSeconds { get; set; } = 30;
/// <summary>Max channel creations per <see cref="ChannelCreateWindowMinutes"/> window.</summary>
public int MaxChannelCreatesPerWindow { get; set; } = 3;
public int ChannelCreateWindowMinutes { get; set; } = 10;
}
+5
View File
@@ -106,6 +106,11 @@ while (true)
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
builder.Services.AddSingleton(uploadLimits);
// ── Spam protection (admin-configurable via the "Spam" section) ──────
var spamOptions = builder.Configuration.GetSection("Spam").Get<SpamOptions>() ?? new SpamOptions();
builder.Services.AddSingleton(spamOptions);
builder.Services.AddSingleton<SpamGuard>();
// Raise the multipart form ceiling to match the configured limits; per-endpoint
// request-body limits are applied at the action from the same values.
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
@@ -13,15 +13,18 @@ public class ChannelService : IChannelService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker;
private readonly SpamGuard _spamGuard;
private readonly ILogger<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
SpamGuard spamGuard,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_spamGuard = spamGuard;
_logger = logger;
}
@@ -74,6 +77,16 @@ public class ChannelService : IChannelService
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
// Channel-creation throttle (spam guard; Mods and above are exempt)
if (_spamGuard.Enabled)
{
var creator = await db.Users.FindAsync(creatorUserId);
var verdict = _spamGuard.CheckChannelCreate(creatorUserId, creator?.Role ?? ServerRole.Member);
if (verdict.Kind != SpamVerdictKind.Allowed)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
verdict.Reason ?? "You're creating channels too fast.");
}
if (await db.Channels.AnyAsync(c => c.Name == channelName))
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
@@ -20,6 +20,7 @@ public class ChatService : IChatService
private readonly IMessageEncryptionService _encryption;
private readonly IChannelService _channelService;
private readonly FileStorageService _fileStorage;
private readonly SpamGuard _spamGuard;
private readonly ILogger<ChatService> _logger;
public ChatService(
@@ -30,6 +31,7 @@ public class ChatService : IChatService
IMessageEncryptionService encryption,
IChannelService channelService,
FileStorageService fileStorage,
SpamGuard spamGuard,
ILogger<ChatService> logger)
{
_scopeFactory = scopeFactory;
@@ -39,6 +41,7 @@ public class ChatService : IChatService
_encryption = encryption;
_channelService = channelService;
_fileStorage = fileStorage;
_spamGuard = spamGuard;
_logger = logger;
}
@@ -102,6 +105,27 @@ public class ChatService : IChatService
{
channelName = channelName.ToLowerInvariant().Trim();
// Join throttle — stops channel-cycling spam from any protocol. Only *first-time*
// joins (no membership yet) count: the client auto-joins every known channel on
// connect/reconnect, and that burst must never trip the guard.
if (_spamGuard.Enabled)
{
using var guardScope = _scopeFactory.CreateScope();
var guardDb = guardScope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var isExistingMember = await guardDb.Channels
.Where(c => c.Name == channelName)
.AnyAsync(c => guardDb.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
if (!isExistingMember)
{
var joiner = await guardDb.Users.FindAsync(userId);
var joinVerdict = _spamGuard.CheckJoin(userId, joiner?.Role ?? ServerRole.Member);
if (joinVerdict.Kind != SpamVerdictKind.Allowed)
return ([], joinVerdict.Reason, false);
}
}
// Delegate channel validation + membership (incl. password gate) to ChannelService
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
if (!success)
@@ -199,6 +223,27 @@ public class ChatService : IChatService
}
}
// Spam guard — operates on the stored content (ciphertext for E2E rooms), never decrypts
if (sender is not null)
{
var verdict = _spamGuard.CheckMessage(userId, sender.Role, plaintext);
switch (verdict.Kind)
{
case SpamVerdictKind.AutoMute:
// Escalate through the normal timed-mute machinery: moderation endpoints
// list it and MuteExpirationService lifts it. Issued by the server itself.
sender.IsMuted = true;
sender.MutedUntil = DateTimeOffset.UtcNow.Add(verdict.MuteDuration);
await db.SaveChangesAsync();
_logger.LogWarning("Spam protection auto-muted {User} for {Minutes} minutes in '{Channel}'",
username, (int)verdict.MuteDuration.TotalMinutes, channelName);
return $"You have been automatically muted for {(int)verdict.MuteDuration.TotalMinutes} minutes (spam protection).";
case SpamVerdictKind.Rejected:
return verdict.Reason;
}
}
// Replies must target an existing message in the same channel
Message? replyTarget = null;
if (replyToMessageId is { } replyId)
+187
View File
@@ -0,0 +1,187 @@
using EchoHub.Core.Models;
using EchoHub.Server.Config;
namespace EchoHub.Server.Services;
public enum SpamVerdictKind
{
Allowed,
Rejected,
/// <summary>The user crossed the violation threshold — the caller should apply a timed mute.</summary>
AutoMute,
}
public readonly record struct SpamVerdict(SpamVerdictKind Kind, string? Reason = null, TimeSpan MuteDuration = default)
{
public static readonly SpamVerdict Allowed = new(SpamVerdictKind.Allowed);
}
/// <summary>
/// In-memory, per-user spam protection consulted from <see cref="ChatService"/> (messages,
/// joins) and <see cref="ChannelService"/> (channel creation), so every ingress protocol —
/// SignalR and IRC alike — shares the same limits. State is per-process and never persisted;
/// auto-mutes land in the normal mute store. Operates only on content the server already
/// stores (for end-to-end encrypted rooms that is ciphertext) — nothing is decrypted.
/// </summary>
public sealed class SpamGuard
{
private readonly SpamOptions _options;
private readonly Lock _lock = new();
private readonly Dictionary<Guid, UserState> _users = [];
// Lazy stale-state pruning so the dictionary can't grow unbounded on busy servers
private const int PruneThreshold = 512;
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
public SpamGuard(SpamOptions options)
{
_options = options;
}
public bool Enabled => _options.Enabled;
private sealed class UserState
{
public readonly Queue<DateTimeOffset> MessageTimes = new();
public readonly Queue<DateTimeOffset> JoinTimes = new();
public readonly Queue<DateTimeOffset> CreateTimes = new();
public readonly Queue<DateTimeOffset> Violations = new();
public string? LastContent;
public int RepeatCount;
public DateTimeOffset LastSeen;
}
/// <summary>
/// Checks a message send. Rejections count as violations; enough violations inside the
/// violation window escalate to <see cref="SpamVerdictKind.AutoMute"/> (evaluated only on
/// rejected messages, so a clean message never mutes anyone).
/// </summary>
public SpamVerdict CheckMessage(Guid userId, ServerRole role, string content, DateTimeOffset? nowOverride = null)
{
if (!_options.Enabled || role >= ServerRole.Mod)
return SpamVerdict.Allowed;
var now = nowOverride ?? DateTimeOffset.UtcNow;
lock (_lock)
{
var state = GetState(userId, now);
// Flood: attempts count too — a client hammering retries must actually pause
Prune(state.MessageTimes, now - TimeSpan.FromSeconds(_options.WindowSeconds));
state.MessageTimes.Enqueue(now);
if (state.MessageTimes.Count > _options.MaxMessagesPerWindow)
return Reject(state, now, "You're sending messages too fast — slow down.");
// Duplicates: identical (trimmed, case-insensitive) content repeated back-to-back
var normalized = content.Trim().ToLowerInvariant();
if (normalized == state.LastContent)
{
if (state.RepeatCount >= _options.MaxDuplicateMessages)
return Reject(state, now, "Duplicate message — say something new.");
state.RepeatCount++;
}
else
{
state.LastContent = normalized;
state.RepeatCount = 1;
}
return SpamVerdict.Allowed;
}
}
/// <summary>Checks a channel join. Rejections count as violations but never auto-mute.</summary>
public SpamVerdict CheckJoin(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
{
return CheckWindow(userId, role, nowOverride,
s => s.JoinTimes,
TimeSpan.FromSeconds(_options.JoinWindowSeconds),
_options.MaxJoinsPerWindow,
"You're joining channels too fast — slow down.");
}
/// <summary>Checks a channel creation. Rejections count as violations but never auto-mute.</summary>
public SpamVerdict CheckChannelCreate(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
{
return CheckWindow(userId, role, nowOverride,
s => s.CreateTimes,
TimeSpan.FromMinutes(_options.ChannelCreateWindowMinutes),
_options.MaxChannelCreatesPerWindow,
"You're creating channels too fast — try again later.");
}
private SpamVerdict CheckWindow(Guid userId, ServerRole role, DateTimeOffset? nowOverride,
Func<UserState, Queue<DateTimeOffset>> queueSelector, TimeSpan window, int max, string reason)
{
if (!_options.Enabled || role >= ServerRole.Mod)
return SpamVerdict.Allowed;
var now = nowOverride ?? DateTimeOffset.UtcNow;
lock (_lock)
{
var state = GetState(userId, now);
var queue = queueSelector(state);
Prune(queue, now - window);
queue.Enqueue(now);
if (queue.Count > max)
{
RecordViolation(state, now);
return new SpamVerdict(SpamVerdictKind.Rejected, reason);
}
return SpamVerdict.Allowed;
}
}
/// <summary>
/// Records a violation and decides between plain rejection and auto-mute escalation.
/// On escalation the violation history is cleared so the user starts fresh post-mute.
/// </summary>
private SpamVerdict Reject(UserState state, DateTimeOffset now, string reason)
{
RecordViolation(state, now);
if (_options.AutoMuteMinutes > 0 && state.Violations.Count >= _options.ViolationThreshold)
{
state.Violations.Clear();
return new SpamVerdict(SpamVerdictKind.AutoMute, reason,
TimeSpan.FromMinutes(_options.AutoMuteMinutes));
}
return new SpamVerdict(SpamVerdictKind.Rejected, reason);
}
private void RecordViolation(UserState state, DateTimeOffset now)
{
Prune(state.Violations, now - TimeSpan.FromMinutes(_options.ViolationWindowMinutes));
state.Violations.Enqueue(now);
}
private UserState GetState(Guid userId, DateTimeOffset now)
{
if (_users.Count > PruneThreshold)
{
foreach (var stale in _users.Where(kv => now - kv.Value.LastSeen > StaleAfter).Select(kv => kv.Key).ToList())
_users.Remove(stale);
}
if (!_users.TryGetValue(userId, out var state))
{
state = new UserState();
_users[userId] = state;
}
state.LastSeen = now;
return state;
}
private static void Prune(Queue<DateTimeOffset> queue, DateTimeOffset cutoff)
{
while (queue.Count > 0 && queue.Peek() < cutoff)
queue.Dequeue();
}
}
@@ -28,6 +28,19 @@
"MaxAvatarSizeMB": 2,
"MaxAttachmentsPerMessage": 10
},
"Spam": {
"Enabled": true,
"MaxMessagesPerWindow": 8,
"WindowSeconds": 5,
"MaxDuplicateMessages": 3,
"AutoMuteMinutes": 5,
"ViolationThreshold": 5,
"ViolationWindowMinutes": 5,
"MaxJoinsPerWindow": 25,
"JoinWindowSeconds": 30,
"MaxChannelCreatesPerWindow": 3,
"ChannelCreateWindowMinutes": 10
},
"Encryption": {
"Key": "",
"EncryptDatabase": false