mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: Implement spam protection with configurable limits and auto-mute escalation
This commit is contained in:
@@ -89,6 +89,7 @@ graph TD
|
|||||||
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
||||||
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
||||||
- **Rate limiting** — in case someone gets too excited
|
- **Rate limiting** — in case someone gets too excited
|
||||||
|
- **Spam protection** — per-user flood/duplicate limits with auto-mute escalation, covering TUI and IRC alike; configurable under `Spam`, Mods exempt
|
||||||
- **Auto-restart** on crash with exponential backoff — it picks itself back up
|
- **Auto-restart** on crash with exponential backoff — it picks itself back up
|
||||||
- **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy
|
- **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy
|
||||||
- **Zero config first run** — generates its own JWT secret and config on launch
|
- **Zero config first run** — generates its own JWT secret and config on launch
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ you send from the TUI now reach your own connected IRC client instantly.
|
|||||||
- **`[open]` images without saving them** — every image attachment now shows `[open] [↓ save original]` beneath its preview. Open views the image in your default browser straight from the server; in end-to-end encrypted rooms (where a browser would only see ciphertext) the client downloads, decrypts with the room key, and opens the image in your OS viewer from a temp file instead. Both actions are individually clickable, Enter on the line opens, and the right-click menu carries both.
|
- **`[open]` images without saving them** — every image attachment now shows `[open] [↓ save original]` beneath its preview. Open views the image in your default browser straight from the server; in end-to-end encrypted rooms (where a browser would only see ciphertext) the client downloads, decrypts with the room key, and opens the image in your OS viewer from a temp file instead. Both actions are individually clickable, Enter on the line opens, and the right-click menu carries both.
|
||||||
- **Attachment links work in a browser** — `GET /api/files/{id}` is now a capability URL: the unguessable GUID in the link is the access token (Discord-CDN style), so attachment links can be opened directly in a browser or shared to IRC without a login token. Images and audio are served inline so the browser displays them instead of forcing a download. Blobs from encrypted rooms remain ciphertext, so their links reveal nothing.
|
- **Attachment links work in a browser** — `GET /api/files/{id}` is now a capability URL: the unguessable GUID in the link is the access token (Discord-CDN style), so attachment links can be opened directly in a browser or shared to IRC without a login token. Images and audio are served inline so the browser displays them instead of forcing a download. Blobs from encrypted rooms remain ciphertext, so their links reveal nothing.
|
||||||
- **IRC gets image links instead of terminal art** — the gateway no longer floods IRC clients with truecolor-ANSI ASCII art for images. Each attachment is now a single line — `[Image: photo.png] https://your-server/api/files/…` — the convention every IRC client understands, and ones like TheLounge or IRCCloud auto-preview. Set the new `Irc:PublicBaseUrl` option (e.g. `"https://chat.example.com"`) so those links come out absolute; unset, they fall back to relative paths as before.
|
- **IRC gets image links instead of terminal art** — the gateway no longer floods IRC clients with truecolor-ANSI ASCII art for images. Each attachment is now a single line — `[Image: photo.png] https://your-server/api/files/…` — the convention every IRC client understands, and ones like TheLounge or IRCCloud auto-preview. Set the new `Irc:PublicBaseUrl` option (e.g. `"https://chat.example.com"`) so those links come out absolute; unset, they fall back to relative paths as before.
|
||||||
|
- **Spam protection** — per-user message flood and duplicate-message limits, join and
|
||||||
|
channel-creation throttles, and automatic escalation: enough rejected sends in a few minutes
|
||||||
|
earns a timed auto-mute through the normal mute system (moderators see it, and it expires on
|
||||||
|
its own). One guard covers every ingress — TUI and IRC clients hit the same limits. Mods and
|
||||||
|
above are exempt, everything is configurable under the new `Spam` section
|
||||||
|
(`Spam:Enabled` master switch, lenient defaults a fast typist won't trip), and the guard only
|
||||||
|
ever sees stored content — encrypted-room messages stay ciphertext.
|
||||||
|
|
||||||
## Improvements
|
## Improvements
|
||||||
|
|
||||||
@@ -62,6 +69,10 @@ you send from the TUI now reach your own connected IRC client instantly.
|
|||||||
## Notes for server operators
|
## Notes for server operators
|
||||||
|
|
||||||
- New config key: `Server:Registration` — `"open"` (default), `"invite"`, or `"closed"`.
|
- New config key: `Server:Registration` — `"open"` (default), `"invite"`, or `"closed"`.
|
||||||
|
- New config section: `Spam` — flood/duplicate/join/create thresholds and auto-mute duration;
|
||||||
|
see `appsettings.example.json`. On by default with lenient limits; `Spam:Enabled: false`
|
||||||
|
turns it all off. Note `Spam:MaxJoinsPerWindow` counts only *first-time* channel joins —
|
||||||
|
keep it above your public channel count so a new member's first connect isn't throttled.
|
||||||
- New REST endpoints: `POST/GET/DELETE /api/invites` (Admin+), `GET /api/users/me/export`,
|
- New REST endpoints: `POST/GET/DELETE /api/invites` (Admin+), `GET /api/users/me/export`,
|
||||||
`DELETE /api/users/me`.
|
`DELETE /api/users/me`.
|
||||||
- One new database migration (`AddInvitesAndReplies`) applies automatically on startup.
|
- One new database migration (`AddInvitesAndReplies`) applies automatically on startup.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -106,6 +106,11 @@ while (true)
|
|||||||
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
|
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
|
||||||
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
|
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
|
||||||
builder.Services.AddSingleton(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
|
// Raise the multipart form ceiling to match the configured limits; per-endpoint
|
||||||
// request-body limits are applied at the action from the same values.
|
// request-body limits are applied at the action from the same values.
|
||||||
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ public class ChannelService : IChannelService
|
|||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly SpamGuard _spamGuard;
|
||||||
private readonly ILogger<ChannelService> _logger;
|
private readonly ILogger<ChannelService> _logger;
|
||||||
|
|
||||||
public ChannelService(
|
public ChannelService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
SpamGuard spamGuard,
|
||||||
ILogger<ChannelService> logger)
|
ILogger<ChannelService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
|
_spamGuard = spamGuard;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,16 @@ public class ChannelService : IChannelService
|
|||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
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))
|
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
||||||
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
|
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class ChatService : IChatService
|
|||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly IChannelService _channelService;
|
private readonly IChannelService _channelService;
|
||||||
private readonly FileStorageService _fileStorage;
|
private readonly FileStorageService _fileStorage;
|
||||||
|
private readonly SpamGuard _spamGuard;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -30,6 +31,7 @@ public class ChatService : IChatService
|
|||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
|
SpamGuard spamGuard,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
@@ -39,6 +41,7 @@ public class ChatService : IChatService
|
|||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
|
_spamGuard = spamGuard;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +105,27 @@ public class ChatService : IChatService
|
|||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
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
|
// Delegate channel validation + membership (incl. password gate) to ChannelService
|
||||||
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
||||||
if (!success)
|
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
|
// Replies must target an existing message in the same channel
|
||||||
Message? replyTarget = null;
|
Message? replyTarget = null;
|
||||||
if (replyToMessageId is { } replyId)
|
if (replyToMessageId is { } replyId)
|
||||||
|
|||||||
@@ -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,
|
"MaxAvatarSizeMB": 2,
|
||||||
"MaxAttachmentsPerMessage": 10
|
"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": {
|
"Encryption": {
|
||||||
"Key": "",
|
"Key": "",
|
||||||
"EncryptDatabase": false
|
"EncryptDatabase": false
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class SpamGuardTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
private static readonly Guid Alice = Guid.NewGuid();
|
||||||
|
|
||||||
|
private static SpamGuard CreateGuard(Action<SpamOptions>? configure = null)
|
||||||
|
{
|
||||||
|
var options = new SpamOptions();
|
||||||
|
configure?.Invoke(options);
|
||||||
|
return new SpamGuard(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message flood ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_UnderLimit_Allowed()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_OverLimit_Rejected()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "msg 9", T0.AddSeconds(1));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
Assert.Contains("too fast", verdict.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_RecoversAfterWindow()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 9; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
|
||||||
|
// The whole window has passed with no attempts — allowed again
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "later", T0.AddSeconds(10));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_IsPerUser()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
var bob = Guid.NewGuid();
|
||||||
|
|
||||||
|
for (var i = 0; i < 9; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
|
||||||
|
var verdict = guard.CheckMessage(bob, ServerRole.Member, "hello", T0.AddSeconds(1));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Duplicates ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicates_UpToLimit_Allowed_ThenRejected()
|
||||||
|
{
|
||||||
|
// Slow enough that the flood window never trips (one message per 10 s)
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var v = guard.CheckMessage(Alice, ServerRole.Member, "same thing", T0.AddSeconds(i * 10));
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, v.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "same thing", T0.AddSeconds(30));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
Assert.Contains("Duplicate", verdict.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicates_CaseAndWhitespaceInsensitive()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.MaxDuplicateMessages = 1);
|
||||||
|
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "Hello World", T0);
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, " hello world ", T0.AddSeconds(10));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicates_ResetByDifferentMessage()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.MaxDuplicateMessages = 1);
|
||||||
|
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "same", T0);
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "different", T0.AddSeconds(10));
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "same", T0.AddSeconds(20));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Escalation → auto-mute ────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escalation_EnoughViolations_ReturnsAutoMute()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.ViolationThreshold = 3);
|
||||||
|
SpamVerdict verdict = default;
|
||||||
|
|
||||||
|
// Keep hammering inside the flood window: 8 allowed, then rejections accumulate
|
||||||
|
for (var i = 0; i < 8 + 3; i++)
|
||||||
|
verdict = guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.AutoMute, verdict.Kind);
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(5), verdict.MuteDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escalation_ClearsViolations_NextRejectionIsPlain()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.ViolationThreshold = 3);
|
||||||
|
|
||||||
|
for (var i = 0; i < 8 + 3; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50));
|
||||||
|
|
||||||
|
// Still inside the flood window — rejected, but the counter restarted
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "again", T0.AddSeconds(2));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escalation_AutoMuteDisabled_StaysRejected()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => { o.ViolationThreshold = 3; o.AutoMuteMinutes = 0; });
|
||||||
|
SpamVerdict verdict = default;
|
||||||
|
|
||||||
|
for (var i = 0; i < 8 + 10; i++)
|
||||||
|
verdict = guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exemptions / master switch ────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ServerRole.Mod)]
|
||||||
|
[InlineData(ServerRole.Admin)]
|
||||||
|
[InlineData(ServerRole.Owner)]
|
||||||
|
public void ModAndAbove_AlwaysAllowed(ServerRole role)
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
var verdict = guard.CheckMessage(Alice, role, "same spam", T0.AddMilliseconds(i * 10));
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_EverythingAllowed()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.Enabled = false);
|
||||||
|
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed,
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "same", T0.AddMilliseconds(i * 10)).Kind);
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0).Kind);
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0).Kind);
|
||||||
|
Assert.False(guard.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join throttle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Joins_OverLimit_Rejected_ThenRecovers()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => { o.MaxJoinsPerWindow = 5; o.JoinWindowSeconds = 30; });
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(i)).Kind);
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(6)).Kind);
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(60)).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel-create throttle ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChannelCreates_OverLimit_Rejected_ThenRecovers()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddSeconds(i)).Kind);
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddSeconds(10)).Kind);
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddMinutes(15)).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JoinAndCreateRejections_NeverAutoMute()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => { o.MaxJoinsPerWindow = 1; o.ViolationThreshold = 2; });
|
||||||
|
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var verdict = guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(i));
|
||||||
|
Assert.NotEqual(SpamVerdictKind.AutoMute, verdict.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user