* Make chat rate limits a general-purpose system.
Intending to use this with ahelps next.
* Rate limt ahelps
Fixes #28762
* Review comments
using Content.Server.Afk;
using Content.Server.Discord;
using Content.Server.GameTicking;
+using Content.Server.Players.RateLimiting;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Mind;
[UsedImplicitly]
public sealed partial class BwoinkSystem : SharedBwoinkSystem
{
+ private const string RateLimitKey = "AdminHelp";
+
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IAdminManager _adminManager = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly GameTicker _gameTicker = default!;
[Dependency] private readonly SharedMindSystem _minds = default!;
[Dependency] private readonly IAfkManager _afkManager = default!;
+ [Dependency] private readonly PlayerRateLimitManager _rateLimit = default!;
[GeneratedRegex(@"^https://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")]
private static partial Regex DiscordRegex();
SubscribeLocalEvent<GameRunLevelChangedEvent>(OnGameRunLevelChanged);
SubscribeNetworkEvent<BwoinkClientTypingUpdated>(OnClientTypingUpdated);
+
+ _rateLimit.Register(
+ RateLimitKey,
+ new RateLimitRegistration
+ {
+ CVarLimitPeriodLength = CCVars.AhelpRateLimitPeriod,
+ CVarLimitCount = CCVars.AhelpRateLimitCount,
+ PlayerLimitedAction = PlayerRateLimitedAction
+ });
+ }
+
+ private void PlayerRateLimitedAction(ICommonSession obj)
+ {
+ RaiseNetworkEvent(
+ new BwoinkTextMessage(obj.UserId, default, Loc.GetString("bwoink-system-rate-limited"), playSound: false),
+ obj.Channel);
}
private void OnOverrideChanged(string obj)
return;
}
+ if (_rateLimit.CountAction(eventArgs.SenderSession, RateLimitKey) != RateLimitStatus.Allowed)
+ return;
+
var escapedText = FormattedMessage.EscapeText(message.Text);
string bwoinkText;
-using System.Runtime.InteropServices;
+using Content.Server.Players.RateLimiting;
using Content.Shared.CCVar;
using Content.Shared.Database;
-using Robust.Shared.Enums;
using Robust.Shared.Player;
-using Robust.Shared.Timing;
namespace Content.Server.Chat.Managers;
internal sealed partial class ChatManager
{
- private readonly Dictionary<ICommonSession, RateLimitDatum> _rateLimitData = new();
+ private const string RateLimitKey = "Chat";
- public bool HandleRateLimit(ICommonSession player)
+ private void RegisterRateLimits()
{
- ref var datum = ref CollectionsMarshal.GetValueRefOrAddDefault(_rateLimitData, player, out _);
- var time = _gameTiming.RealTime;
- if (datum.CountExpires < time)
- {
- // Period expired, reset it.
- var periodLength = _configurationManager.GetCVar(CCVars.ChatRateLimitPeriod);
- datum.CountExpires = time + TimeSpan.FromSeconds(periodLength);
- datum.Count = 0;
- datum.Announced = false;
- }
-
- var maxCount = _configurationManager.GetCVar(CCVars.ChatRateLimitCount);
- datum.Count += 1;
-
- if (datum.Count <= maxCount)
- return true;
-
- // Breached rate limits, inform admins if configured.
- if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins))
- {
- if (datum.NextAdminAnnounce < time)
+ _rateLimitManager.Register(RateLimitKey,
+ new RateLimitRegistration
{
- SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name)));
- var delay = _configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdminsDelay);
- datum.NextAdminAnnounce = time + TimeSpan.FromSeconds(delay);
- }
- }
-
- if (!datum.Announced)
- {
- DispatchServerMessage(player, Loc.GetString("chat-manager-rate-limited"), suppressLog: true);
- _adminLogger.Add(LogType.ChatRateLimited, LogImpact.Medium, $"Player {player} breached chat rate limits");
-
- datum.Announced = true;
- }
-
- return false;
+ CVarLimitPeriodLength = CCVars.ChatRateLimitPeriod,
+ CVarLimitCount = CCVars.ChatRateLimitCount,
+ CVarAdminAnnounceDelay = CCVars.ChatRateLimitAnnounceAdminsDelay,
+ PlayerLimitedAction = RateLimitPlayerLimited,
+ AdminAnnounceAction = RateLimitAlertAdmins,
+ AdminLogType = LogType.ChatRateLimited,
+ });
}
- private void PlayerStatusChanged(object? sender, SessionStatusEventArgs e)
+ private void RateLimitPlayerLimited(ICommonSession player)
{
- if (e.NewStatus == SessionStatus.Disconnected)
- _rateLimitData.Remove(e.Session);
+ DispatchServerMessage(player, Loc.GetString("chat-manager-rate-limited"), suppressLog: true);
}
- private struct RateLimitDatum
+ private void RateLimitAlertAdmins(ICommonSession player)
{
- /// <summary>
- /// Time stamp (relative to <see cref="IGameTiming.RealTime"/>) this rate limit period will expire at.
- /// </summary>
- public TimeSpan CountExpires;
-
- /// <summary>
- /// How many messages have been sent in the current rate limit period.
- /// </summary>
- public int Count;
-
- /// <summary>
- /// Have we announced to the player that they've been blocked in this rate limit period?
- /// </summary>
- public bool Announced;
+ if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins))
+ SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name)));
+ }
- /// <summary>
- /// Time stamp (relative to <see cref="IGameTiming.RealTime"/>) of the
- /// next time we can send an announcement to admins about rate limit breach.
- /// </summary>
- public TimeSpan NextAdminAnnounce;
+ public RateLimitStatus HandleRateLimit(ICommonSession player)
+ {
+ return _rateLimitManager.CountAction(player, RateLimitKey);
}
}
using Content.Server.Administration.Managers;
using Content.Server.Administration.Systems;
using Content.Server.MoMMI;
+using Content.Server.Players.RateLimiting;
using Content.Server.Preferences.Managers;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Chat;
using Content.Shared.Database;
using Content.Shared.Mind;
-using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Replays;
-using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Chat.Managers
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
[Dependency] private readonly INetConfigurationManager _netConfigManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
- [Dependency] private readonly IGameTiming _gameTiming = default!;
- [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly PlayerRateLimitManager _rateLimitManager = default!;
/// <summary>
/// The maximum length a player-sent message can be sent
_configurationManager.OnValueChanged(CCVars.OocEnabled, OnOocEnabledChanged, true);
_configurationManager.OnValueChanged(CCVars.AdminOocEnabled, OnAdminOocEnabledChanged, true);
- _playerManager.PlayerStatusChanged += PlayerStatusChanged;
+ RegisterRateLimits();
}
private void OnOocEnabledChanged(bool val)
/// <param name="type">The type of message.</param>
public void TrySendOOCMessage(ICommonSession player, string message, OOCChatType type)
{
- if (!HandleRateLimit(player))
+ if (HandleRateLimit(player) != RateLimitStatus.Allowed)
return;
// Check if message exceeds the character limit
using System.Diagnostics.CodeAnalysis;
+using Content.Server.Players;
+using Content.Server.Players.RateLimiting;
using Content.Shared.Administration;
using Content.Shared.Chat;
using Robust.Shared.Network;
/// </summary>
/// <param name="player">The player sending a chat message.</param>
/// <returns>False if the player has violated rate limits and should be blocked from sending further messages.</returns>
- bool HandleRateLimit(ICommonSession player);
+ RateLimitStatus HandleRateLimit(ICommonSession player);
}
}
using Content.Server.Chat.Managers;
using Content.Server.Examine;
using Content.Server.GameTicking;
+using Content.Server.Players.RateLimiting;
using Content.Server.Speech.Components;
using Content.Server.Speech.EntitySystems;
using Content.Server.Station.Components;
return;
}
- if (player != null && !_chatManager.HandleRateLimit(player))
+ if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed)
return;
// Sus
if (!CanSendInGame(message, shell, player))
return;
- if (player != null && !_chatManager.HandleRateLimit(player))
+ if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed)
return;
// It doesn't make any sense for a non-player to send in-game OOC messages, whereas non-players may be sending
using Content.Server.IoC;
using Content.Server.Maps;
using Content.Server.NodeContainer.NodeGroups;
+using Content.Server.Players;
using Content.Server.Players.JobWhitelist;
using Content.Server.Players.PlayTimeTracking;
+using Content.Server.Players.RateLimiting;
using Content.Server.Preferences.Managers;
using Content.Server.ServerInfo;
using Content.Server.ServerUpdates;
_updateManager.Initialize();
_playTimeTracking.Initialize();
IoCManager.Resolve<JobWhitelistManager>().Initialize();
+ IoCManager.Resolve<PlayerRateLimitManager>().Initialize();
}
}
using Content.Server.Maps;
using Content.Server.MoMMI;
using Content.Server.NodeContainer.NodeGroups;
+using Content.Server.Players;
using Content.Server.Players.JobWhitelist;
using Content.Server.Players.PlayTimeTracking;
+using Content.Server.Players.RateLimiting;
using Content.Server.Preferences.Managers;
using Content.Server.ServerInfo;
using Content.Server.ServerUpdates;
IoCManager.Register<ISharedPlaytimeManager, PlayTimeTrackingManager>();
IoCManager.Register<ServerApi>();
IoCManager.Register<JobWhitelistManager>();
+ IoCManager.Register<PlayerRateLimitManager>();
}
}
}
--- /dev/null
+using System.Runtime.InteropServices;
+using Content.Server.Administration.Logs;
+using Content.Shared.Database;
+using Robust.Server.Player;
+using Robust.Shared.Configuration;
+using Robust.Shared.Enums;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Players.RateLimiting;
+
+/// <summary>
+/// General-purpose system to rate limit actions taken by clients, such as chat messages.
+/// </summary>
+/// <remarks>
+/// <para>
+/// Different categories of rate limits must be registered ahead of time by calling <see cref="Register"/>.
+/// Once registered, you can simply call <see cref="CountAction"/> to count a rate-limited action for a player.
+/// </para>
+/// <para>
+/// This system is intended for rate limiting player actions over short periods,
+/// to ward against spam that can cause technical issues such as admin client load.
+/// It should not be used for in-game actions or similar.
+/// </para>
+/// <para>
+/// Rate limits are reset when a client reconnects.
+/// This should not be an issue for the reasonably short rate limit periods this system is intended for.
+/// </para>
+/// </remarks>
+/// <seealso cref="RateLimitRegistration"/>
+public sealed class PlayerRateLimitManager
+{
+ [Dependency] private readonly IAdminLogManager _adminLog = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+
+ private readonly Dictionary<string, RegistrationData> _registrations = new();
+ private readonly Dictionary<ICommonSession, Dictionary<string, RateLimitDatum>> _rateLimitData = new();
+
+ /// <summary>
+ /// Count and validate an action performed by a player against rate limits.
+ /// </summary>
+ /// <param name="player">The player performing the action.</param>
+ /// <param name="key">The key string that was previously used to register a rate limit category.</param>
+ /// <returns>Whether the action counted should be blocked due to surpassing rate limits or not.</returns>
+ /// <exception cref="ArgumentException">
+ /// <paramref name="player"/> is not a connected player
+ /// OR <paramref name="key"/> is not a registered rate limit category.
+ /// </exception>
+ /// <seealso cref="Register"/>
+ public RateLimitStatus CountAction(ICommonSession player, string key)
+ {
+ if (player.Status == SessionStatus.Disconnected)
+ throw new ArgumentException("Player is not connected");
+ if (!_registrations.TryGetValue(key, out var registration))
+ throw new ArgumentException($"Unregistered key: {key}");
+
+ var playerData = _rateLimitData.GetOrNew(player);
+ ref var datum = ref CollectionsMarshal.GetValueRefOrAddDefault(playerData, key, out _);
+ var time = _gameTiming.RealTime;
+ if (datum.CountExpires < time)
+ {
+ // Period expired, reset it.
+ datum.CountExpires = time + registration.LimitPeriod;
+ datum.Count = 0;
+ datum.Announced = false;
+ }
+
+ datum.Count += 1;
+
+ if (datum.Count <= registration.LimitCount)
+ return RateLimitStatus.Allowed;
+
+ // Breached rate limits, inform admins if configured.
+ if (registration.AdminAnnounceDelay is { } cvarAnnounceDelay)
+ {
+ if (datum.NextAdminAnnounce < time)
+ {
+ registration.Registration.AdminAnnounceAction!(player);
+ datum.NextAdminAnnounce = time + cvarAnnounceDelay;
+ }
+ }
+
+ if (!datum.Announced)
+ {
+ registration.Registration.PlayerLimitedAction(player);
+ _adminLog.Add(
+ registration.Registration.AdminLogType,
+ LogImpact.Medium,
+ $"Player {player} breached '{key}' rate limit ");
+
+ datum.Announced = true;
+ }
+
+ return RateLimitStatus.Blocked;
+ }
+
+ /// <summary>
+ /// Register a new rate limit category.
+ /// </summary>
+ /// <param name="key">
+ /// The key string that will be referred to later with <see cref="CountAction"/>.
+ /// Must be unique and should probably just be a constant somewhere.
+ /// </param>
+ /// <param name="registration">The data specifying the rate limit's parameters.</param>
+ /// <exception cref="InvalidOperationException"><paramref name="key"/> has already been registered.</exception>
+ /// <exception cref="ArgumentException"><paramref name="registration"/> is invalid.</exception>
+ public void Register(string key, RateLimitRegistration registration)
+ {
+ if (_registrations.ContainsKey(key))
+ throw new InvalidOperationException($"Key already registered: {key}");
+
+ var data = new RegistrationData
+ {
+ Registration = registration,
+ };
+
+ if ((registration.AdminAnnounceAction == null) != (registration.CVarAdminAnnounceDelay == null))
+ {
+ throw new ArgumentException(
+ $"Must set either both {nameof(registration.AdminAnnounceAction)} and {nameof(registration.CVarAdminAnnounceDelay)} or neither");
+ }
+
+ _cfg.OnValueChanged(
+ registration.CVarLimitCount,
+ i => data.LimitCount = i,
+ invokeImmediately: true);
+ _cfg.OnValueChanged(
+ registration.CVarLimitPeriodLength,
+ i => data.LimitPeriod = TimeSpan.FromSeconds(i),
+ invokeImmediately: true);
+
+ if (registration.CVarAdminAnnounceDelay != null)
+ {
+ _cfg.OnValueChanged(
+ registration.CVarLimitCount,
+ i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i),
+ invokeImmediately: true);
+ }
+
+ _registrations.Add(key, data);
+ }
+
+ /// <summary>
+ /// Initialize the manager's functionality at game startup.
+ /// </summary>
+ public void Initialize()
+ {
+ _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged;
+ }
+
+ private void PlayerManagerOnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
+ {
+ if (e.NewStatus == SessionStatus.Disconnected)
+ _rateLimitData.Remove(e.Session);
+ }
+
+ private sealed class RegistrationData
+ {
+ public required RateLimitRegistration Registration { get; init; }
+ public TimeSpan LimitPeriod { get; set; }
+ public int LimitCount { get; set; }
+ public TimeSpan? AdminAnnounceDelay { get; set; }
+ }
+
+ private struct RateLimitDatum
+ {
+ /// <summary>
+ /// Time stamp (relative to <see cref="IGameTiming.RealTime"/>) this rate limit period will expire at.
+ /// </summary>
+ public TimeSpan CountExpires;
+
+ /// <summary>
+ /// How many actions have been done in the current rate limit period.
+ /// </summary>
+ public int Count;
+
+ /// <summary>
+ /// Have we announced to the player that they've been blocked in this rate limit period?
+ /// </summary>
+ public bool Announced;
+
+ /// <summary>
+ /// Time stamp (relative to <see cref="IGameTiming.RealTime"/>) of the
+ /// next time we can send an announcement to admins about rate limit breach.
+ /// </summary>
+ public TimeSpan NextAdminAnnounce;
+ }
+}
+
+/// <summary>
+/// Contains all data necessary to register a rate limit with <see cref="PlayerRateLimitManager.Register"/>.
+/// </summary>
+public sealed class RateLimitRegistration
+{
+ /// <summary>
+ /// CVar that controls the period over which the rate limit is counted, measured in seconds.
+ /// </summary>
+ public required CVarDef<int> CVarLimitPeriodLength { get; init; }
+
+ /// <summary>
+ /// CVar that controls how many actions are allowed in a single rate limit period.
+ /// </summary>
+ public required CVarDef<int> CVarLimitCount { get; init; }
+
+ /// <summary>
+ /// An action that gets invoked when this rate limit has been breached by a player.
+ /// </summary>
+ /// <remarks>
+ /// This can be used for informing players or taking administrative action.
+ /// </remarks>
+ public required Action<ICommonSession> PlayerLimitedAction { get; init; }
+
+ /// <summary>
+ /// CVar that controls the minimum delay between admin notifications, measured in seconds.
+ /// This can be omitted to have no admin notification system.
+ /// </summary>
+ /// <remarks>
+ /// If set, <see cref="AdminAnnounceAction"/> must be set too.
+ /// </remarks>
+ public CVarDef<int>? CVarAdminAnnounceDelay { get; init; }
+
+ /// <summary>
+ /// An action that gets invoked when a rate limit was breached and admins should be notified.
+ /// </summary>
+ /// <remarks>
+ /// If set, <see cref="CVarAdminAnnounceDelay"/> must be set too.
+ /// </remarks>
+ public Action<ICommonSession>? AdminAnnounceAction { get; init; }
+
+ /// <summary>
+ /// Log type used to log rate limit violations to the admin logs system.
+ /// </summary>
+ public LogType AdminLogType { get; init; } = LogType.RateLimited;
+}
+
+/// <summary>
+/// Result of a rate-limited operation.
+/// </summary>
+/// <seealso cref="PlayerRateLimitManager.CountAction"/>
+public enum RateLimitStatus : byte
+{
+ /// <summary>
+ /// The action was not blocked by the rate limit.
+ /// </summary>
+ Allowed,
+
+ /// <summary>
+ /// The action was blocked by the rate limit.
+ /// </summary>
+ Blocked,
+}
ChatRateLimited = 87,
AtmosTemperatureChanged = 88,
DeviceNetwork = 89,
- StoreRefund = 90
+ StoreRefund = 90,
+
+ /// <summary>
+ /// User was rate-limited for some spam action.
+ /// </summary>
+ /// <remarks>
+ /// This is a default value used by <c>PlayerRateLimitManager</c>, though users can use different log types.
+ /// </remarks>
+ RateLimited = 91,
}
public static readonly CVarDef<bool> AdminBypassMaxPlayers =
CVarDef.Create("admin.bypass_max_players", true, CVar.SERVERONLY);
+ /*
+ * AHELP
+ */
+
+ /// <summary>
+ /// Ahelp rate limit values are accounted in periods of this size (seconds).
+ /// After the period has passed, the count resets.
+ /// </summary>
+ /// <seealso cref="AhelpRateLimitCount"/>
+ public static readonly CVarDef<int> AhelpRateLimitPeriod =
+ CVarDef.Create("ahelp.rate_limit_period", 2, CVar.SERVERONLY);
+
+ /// <summary>
+ /// How many ahelp messages are allowed in a single rate limit period.
+ /// </summary>
+ /// <seealso cref="AhelpRateLimitPeriod"/>
+ public static readonly CVarDef<int> AhelpRateLimitCount =
+ CVarDef.Create("ahelp.rate_limit_count", 10, CVar.SERVERONLY);
+
/*
* Explosions
*/
admin-bwoink-play-sound = Bwoink?
bwoink-title-none-selected = None selected
+
+bwoink-system-rate-limited = System: you are sending messages too quickly.