Rate limit ahelps (#29219)

* Make chat rate limits a general-purpose system.

Intending to use this with ahelps next.

* Rate limt ahelps

Fixes #28762

* Review comments
This commit is contained in:
Pieter-Jan Briers 2024-06-21 00:13:02 +02:00 committed by null
parent 60bf5fe896
commit 01ecd1d28b
No known key found for this signature in database
GPG Key ID: 212F05528FD678BE
11 changed files with 344 additions and 74 deletions

View File

@ -9,6 +9,7 @@ using Content.Server.Administration.Managers;
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;
@ -27,6 +28,8 @@ namespace Content.Server.Administration.Systems
[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!;
@ -35,6 +38,7 @@ namespace Content.Server.Administration.Systems
[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();
@ -80,6 +84,22 @@ namespace Content.Server.Administration.Systems
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)
@ -395,6 +415,9 @@ namespace Content.Server.Administration.Systems
return;
}
if (_rateLimit.CountAction(eventArgs.SenderSession, RateLimitKey) != RateLimitStatus.Allowed)
return;
var escapedText = FormattedMessage.EscapeText(message.Text);
string bwoinkText;

View File

@ -1,84 +1,41 @@
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)
_rateLimitManager.Register(RateLimitKey,
new RateLimitRegistration
{
// Period expired, reset it.
var periodLength = _configurationManager.GetCVar(CCVars.ChatRateLimitPeriod);
datum.CountExpires = time + TimeSpan.FromSeconds(periodLength);
datum.Count = 0;
datum.Announced = false;
CVarLimitPeriodLength = CCVars.ChatRateLimitPeriod,
CVarLimitCount = CCVars.ChatRateLimitCount,
CVarAdminAnnounceDelay = CCVars.ChatRateLimitAnnounceAdminsDelay,
PlayerLimitedAction = RateLimitPlayerLimited,
AdminAnnounceAction = RateLimitAlertAdmins,
AdminLogType = LogType.ChatRateLimited,
});
}
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)
{
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)
private void RateLimitPlayerLimited(ICommonSession player)
{
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;
}
private void PlayerStatusChanged(object? sender, SessionStatusEventArgs e)
private void RateLimitAlertAdmins(ICommonSession player)
{
if (e.NewStatus == SessionStatus.Disconnected)
_rateLimitData.Remove(e.Session);
if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins))
SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name)));
}
private struct RateLimitDatum
public RateLimitStatus HandleRateLimit(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;
/// <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;
return _rateLimitManager.CountAction(player, RateLimitKey);
}
}

View File

@ -5,18 +5,17 @@ using Content.Server.Administration.Logs;
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
@ -43,8 +42,7 @@ 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
@ -64,7 +62,7 @@ namespace Content.Server.Chat.Managers
_configurationManager.OnValueChanged(CCVars.OocEnabled, OnOocEnabledChanged, true);
_configurationManager.OnValueChanged(CCVars.AdminOocEnabled, OnAdminOocEnabledChanged, true);
_playerManager.PlayerStatusChanged += PlayerStatusChanged;
RegisterRateLimits();
}
private void OnOocEnabledChanged(bool val)
@ -206,7 +204,7 @@ namespace Content.Server.Chat.Managers
/// <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

View File

@ -1,4 +1,6 @@
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;
@ -50,6 +52,6 @@ namespace Content.Server.Chat.Managers
/// </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);
}
}

View File

@ -6,6 +6,7 @@ using Content.Server.Administration.Managers;
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.Nyanotrasen.Chat;
@ -189,7 +190,7 @@ public sealed partial class ChatSystem : SharedChatSystem
return;
}
if (player != null && !_chatManager.HandleRateLimit(player))
if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed)
return;
// Sus
@ -282,7 +283,7 @@ public sealed partial class ChatSystem : SharedChatSystem
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

View File

@ -14,8 +14,10 @@ using Content.Server.Info;
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;
@ -109,6 +111,7 @@ namespace Content.Server.Entry
_updateManager.Initialize();
_playTimeTracking.Initialize();
IoCManager.Resolve<JobWhitelistManager>().Initialize();
IoCManager.Resolve<PlayerRateLimitManager>().Initialize();
}
}

View File

@ -13,8 +13,10 @@ using Content.Server.Info;
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;
@ -63,6 +65,7 @@ namespace Content.Server.IoC
IoCManager.Register<ISharedPlaytimeManager, PlayTimeTrackingManager>();
IoCManager.Register<ServerApi>();
IoCManager.Register<JobWhitelistManager>();
IoCManager.Register<PlayerRateLimitManager>();
}
}
}

View File

@ -0,0 +1,254 @@
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,
}

View File

@ -98,5 +98,13 @@ public enum LogType
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,
}

View File

@ -871,6 +871,25 @@ namespace Content.Shared.CCVar
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
*/

View File

@ -14,3 +14,5 @@ bwoink-system-typing-indicator = {$players} {$count ->
admin-bwoink-play-sound = Bwoink?
bwoink-title-none-selected = None selected
bwoink-system-rate-limited = System: you are sending messages too quickly.