From f1f1fc1dc3cb5543af9b52850b253a88cc62b949 Mon Sep 17 00:00:00 2001 From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Date: Mon, 30 Sep 2024 01:19:00 +1300 Subject: [PATCH] Add interaction rate limits (#32527) * Move PlayerRateLimitManager to shared * Add interaction rate limits * uncap tests --- Content.Client/Chat/Managers/ChatManager.cs | 10 ++ Content.Client/Chat/Managers/IChatManager.cs | 4 +- Content.Client/IoC/ClientContentIoC.cs | 8 +- .../RateLimiting/PlayerRateLimitManager.cs | 23 ++++ Content.IntegrationTests/PoolManager.Cvars.cs | 4 +- .../Administration/Systems/BwoinkSystem.cs | 11 +- .../Chat/Managers/ChatManager.RateLimit.cs | 21 ++- Content.Server/Chat/Managers/ChatManager.cs | 1 + Content.Server/Chat/Managers/IChatManager.cs | 9 +- Content.Server/Chat/Systems/ChatSystem.cs | 1 + Content.Server/IoC/ServerContentIoC.cs | 6 +- .../RateLimiting/PlayerRateLimitManager.cs | 122 ++---------------- Content.Shared/CCVar/CCVars.cs | 47 +++++-- Content.Shared/Chat/ISharedChatManager.cs | 8 ++ .../Interaction/SharedInteractionSystem.cs | 32 ++++- .../RateLimiting/RateLimitRegistration.cs | 76 +++++++++++ .../SharedPlayerRateLimitManager.cs | 55 ++++++++ .../en-US/interaction/interaction-system.ftl | 3 +- 18 files changed, 277 insertions(+), 164 deletions(-) create mode 100644 Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs create mode 100644 Content.Shared/Chat/ISharedChatManager.cs create mode 100644 Content.Shared/Players/RateLimiting/RateLimitRegistration.cs create mode 100644 Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs diff --git a/Content.Client/Chat/Managers/ChatManager.cs b/Content.Client/Chat/Managers/ChatManager.cs index e428d30f20c..68707e021c5 100644 --- a/Content.Client/Chat/Managers/ChatManager.cs +++ b/Content.Client/Chat/Managers/ChatManager.cs @@ -21,6 +21,16 @@ public void Initialize() _sawmill.Level = LogLevel.Info; } + public void SendAdminAlert(string message) + { + // See server-side manager. This just exists for shared code. + } + + public void SendAdminAlert(EntityUid player, string message) + { + // See server-side manager. This just exists for shared code. + } + public void SendMessage(string text, ChatSelectChannel channel) { var str = text.ToString(); diff --git a/Content.Client/Chat/Managers/IChatManager.cs b/Content.Client/Chat/Managers/IChatManager.cs index 6464ca10196..62a97c6bd82 100644 --- a/Content.Client/Chat/Managers/IChatManager.cs +++ b/Content.Client/Chat/Managers/IChatManager.cs @@ -2,10 +2,8 @@ namespace Content.Client.Chat.Managers { - public interface IChatManager + public interface IChatManager : ISharedChatManager { - void Initialize(); - public void SendMessage(string text, ChatSelectChannel channel); } } diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index 1fd237cf3e3..e643552f70b 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -18,8 +18,11 @@ using Content.Client.Voting; using Content.Shared.Administration.Logs; using Content.Client.Lobby; +using Content.Client.Players.RateLimiting; using Content.Shared.Administration.Managers; +using Content.Shared.Chat; using Content.Shared.Players.PlayTimeTracking; +using Content.Shared.Players.RateLimiting; namespace Content.Client.IoC { @@ -31,6 +34,7 @@ public static void Register() collection.Register(); collection.Register(); + collection.Register(); collection.Register(); collection.Register(); collection.Register(); @@ -47,10 +51,12 @@ public static void Register() collection.Register(); collection.Register(); collection.Register(); - collection.Register(); + collection.Register(); collection.Register(); collection.Register(); collection.Register(); + collection.Register(); + collection.Register(); } } } diff --git a/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs new file mode 100644 index 00000000000..e79eadd92b1 --- /dev/null +++ b/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs @@ -0,0 +1,23 @@ +using Content.Shared.Players.RateLimiting; +using Robust.Shared.Player; + +namespace Content.Client.Players.RateLimiting; + +public sealed class PlayerRateLimitManager : SharedPlayerRateLimitManager +{ + public override RateLimitStatus CountAction(ICommonSession player, string key) + { + // TODO Rate-Limit + // Add support for rate limit prediction + // I.e., dont mis-predict just because somebody is clicking too quickly. + return RateLimitStatus.Allowed; + } + + public override void Register(string key, RateLimitRegistration registration) + { + } + + public override void Initialize() + { + } +} diff --git a/Content.IntegrationTests/PoolManager.Cvars.cs b/Content.IntegrationTests/PoolManager.Cvars.cs index bcd48f82380..23f0ded7df2 100644 --- a/Content.IntegrationTests/PoolManager.Cvars.cs +++ b/Content.IntegrationTests/PoolManager.Cvars.cs @@ -36,7 +36,9 @@ private static readonly (string cvar, string value)[] TestCvars = (CCVars.ConfigPresetDevelopment.Name, "false"), (CCVars.AdminLogsEnabled.Name, "false"), (CCVars.AutosaveEnabled.Name, "false"), - (CVars.NetBufferSize.Name, "0") + (CVars.NetBufferSize.Name, "0"), + (CCVars.InteractionRateLimitCount.Name, "9999999"), + (CCVars.InteractionRateLimitPeriod.Name, "0.1"), }; public static async Task SetupCVars(RobustIntegrationTest.IntegrationInstance instance, PoolSettings settings) diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 893de4aba5b..1efc0a9d562 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -15,6 +15,7 @@ using Content.Shared.CCVar; using Content.Shared.GameTicking; using Content.Shared.Mind; +using Content.Shared.Players.RateLimiting; using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared; @@ -104,12 +105,10 @@ public override void Initialize() _rateLimit.Register( RateLimitKey, - new RateLimitRegistration - { - CVarLimitPeriodLength = CCVars.AhelpRateLimitPeriod, - CVarLimitCount = CCVars.AhelpRateLimitCount, - PlayerLimitedAction = PlayerRateLimitedAction - }); + new RateLimitRegistration(CCVars.AhelpRateLimitPeriod, + CCVars.AhelpRateLimitCount, + PlayerRateLimitedAction) + ); } private void PlayerRateLimitedAction(ICommonSession obj) diff --git a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs index 45e7d2e20d0..ccb38166a6d 100644 --- a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs +++ b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs @@ -1,6 +1,6 @@ -using Content.Server.Players.RateLimiting; using Content.Shared.CCVar; using Content.Shared.Database; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Player; namespace Content.Server.Chat.Managers; @@ -12,15 +12,13 @@ internal sealed partial class ChatManager private void RegisterRateLimits() { _rateLimitManager.Register(RateLimitKey, - new RateLimitRegistration - { - CVarLimitPeriodLength = CCVars.ChatRateLimitPeriod, - CVarLimitCount = CCVars.ChatRateLimitCount, - CVarAdminAnnounceDelay = CCVars.ChatRateLimitAnnounceAdminsDelay, - PlayerLimitedAction = RateLimitPlayerLimited, - AdminAnnounceAction = RateLimitAlertAdmins, - AdminLogType = LogType.ChatRateLimited, - }); + new RateLimitRegistration(CCVars.ChatRateLimitPeriod, + CCVars.ChatRateLimitCount, + RateLimitPlayerLimited, + CCVars.ChatRateLimitAnnounceAdminsDelay, + RateLimitAlertAdmins, + LogType.ChatRateLimited) + ); } private void RateLimitPlayerLimited(ICommonSession player) @@ -30,8 +28,7 @@ private void RateLimitPlayerLimited(ICommonSession player) private void RateLimitAlertAdmins(ICommonSession player) { - if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins)) - SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); + SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); } public RateLimitStatus HandleRateLimit(ICommonSession player) diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index 02f718daef0..75c46abe37b 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -12,6 +12,7 @@ using Content.Shared.Chat; using Content.Shared.Database; using Content.Shared.Mind; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs index 76fa91d8474..23211c28fa0 100644 --- a/Content.Server/Chat/Managers/IChatManager.cs +++ b/Content.Server/Chat/Managers/IChatManager.cs @@ -1,17 +1,14 @@ using System.Diagnostics.CodeAnalysis; -using Content.Server.Players; -using Content.Server.Players.RateLimiting; using Content.Shared.Administration; using Content.Shared.Chat; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Network; using Robust.Shared.Player; namespace Content.Server.Chat.Managers { - public interface IChatManager + public interface IChatManager : ISharedChatManager { - void Initialize(); - /// /// Dispatch a server announcement to every connected player. /// @@ -26,8 +23,6 @@ public interface IChatManager void SendHookOOC(string sender, string message); void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist = null, AdminFlags? flagWhitelist = null); void SendAdminAnnouncementMessage(ICommonSession player, string message, bool suppressLog = true); - void SendAdminAlert(string message); - void SendAdminAlert(EntityUid player, string message); void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null); diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 24937ea4b9f..624c18130b0 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -20,6 +20,7 @@ using Content.Shared.IdentityManagement; using Content.Shared.Mobs.Systems; using Content.Shared.Players; +using Content.Shared.Players.RateLimiting; using Content.Shared.Radio; using Content.Shared.Whitelist; using Robust.Server.Player; diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 3851f145c40..d7f6b85eb60 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -14,8 +14,6 @@ using Content.Server.Maps; using Content.Server.MoMMI; using Content.Server.NodeContainer.NodeGroups; -using Content.Server.Objectives; -using Content.Server.Players; using Content.Server.Players.JobWhitelist; using Content.Server.Players.PlayTimeTracking; using Content.Server.Players.RateLimiting; @@ -26,8 +24,10 @@ using Content.Server.Worldgen.Tools; using Content.Shared.Administration.Logs; using Content.Shared.Administration.Managers; +using Content.Shared.Chat; using Content.Shared.Kitchen; using Content.Shared.Players.PlayTimeTracking; +using Content.Shared.Players.RateLimiting; namespace Content.Server.IoC { @@ -36,6 +36,7 @@ internal static class ServerContentIoC public static void Register() { IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); @@ -68,6 +69,7 @@ public static void Register() IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); } } diff --git a/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs index 59f086f9c31..a3b4d4a5364 100644 --- a/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs +++ b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using Content.Server.Administration.Logs; using Content.Shared.Database; +using Content.Shared.Players.RateLimiting; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Enums; @@ -10,26 +11,7 @@ namespace Content.Server.Players.RateLimiting; -/// -/// General-purpose system to rate limit actions taken by clients, such as chat messages. -/// -/// -/// -/// Different categories of rate limits must be registered ahead of time by calling . -/// Once registered, you can simply call to count a rate-limited action for a player. -/// -/// -/// 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. -/// -/// -/// 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. -/// -/// -/// -public sealed class PlayerRateLimitManager +public sealed class PlayerRateLimitManager : SharedPlayerRateLimitManager { [Dependency] private readonly IAdminLogManager _adminLog = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; @@ -39,18 +21,7 @@ public sealed class PlayerRateLimitManager private readonly Dictionary _registrations = new(); private readonly Dictionary> _rateLimitData = new(); - /// - /// Count and validate an action performed by a player against rate limits. - /// - /// The player performing the action. - /// The key string that was previously used to register a rate limit category. - /// Whether the action counted should be blocked due to surpassing rate limits or not. - /// - /// is not a connected player - /// OR is not a registered rate limit category. - /// - /// - public RateLimitStatus CountAction(ICommonSession player, string key) + public override RateLimitStatus CountAction(ICommonSession player, string key) { if (player.Status == SessionStatus.Disconnected) throw new ArgumentException("Player is not connected"); @@ -74,7 +45,8 @@ public RateLimitStatus CountAction(ICommonSession player, string key) return RateLimitStatus.Allowed; // Breached rate limits, inform admins if configured. - if (registration.AdminAnnounceDelay is { } cvarAnnounceDelay) + // Negative delays can be used to disable admin announcements. + if (registration.AdminAnnounceDelay is {TotalSeconds: >= 0} cvarAnnounceDelay) { if (datum.NextAdminAnnounce < time) { @@ -85,7 +57,7 @@ public RateLimitStatus CountAction(ICommonSession player, string key) if (!datum.Announced) { - registration.Registration.PlayerLimitedAction(player); + registration.Registration.PlayerLimitedAction?.Invoke(player); _adminLog.Add( registration.Registration.AdminLogType, LogImpact.Medium, @@ -97,17 +69,7 @@ public RateLimitStatus CountAction(ICommonSession player, string key) return RateLimitStatus.Blocked; } - /// - /// Register a new rate limit category. - /// - /// - /// The key string that will be referred to later with . - /// Must be unique and should probably just be a constant somewhere. - /// - /// The data specifying the rate limit's parameters. - /// has already been registered. - /// is invalid. - public void Register(string key, RateLimitRegistration registration) + public override void Register(string key, RateLimitRegistration registration) { if (_registrations.ContainsKey(key)) throw new InvalidOperationException($"Key already registered: {key}"); @@ -135,7 +97,7 @@ public void Register(string key, RateLimitRegistration registration) if (registration.CVarAdminAnnounceDelay != null) { _cfg.OnValueChanged( - registration.CVarLimitCount, + registration.CVarAdminAnnounceDelay, i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i), invokeImmediately: true); } @@ -143,10 +105,7 @@ public void Register(string key, RateLimitRegistration registration) _registrations.Add(key, data); } - /// - /// Initialize the manager's functionality at game startup. - /// - public void Initialize() + public override void Initialize() { _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged; } @@ -189,66 +148,3 @@ private struct RateLimitDatum public TimeSpan NextAdminAnnounce; } } - -/// -/// Contains all data necessary to register a rate limit with . -/// -public sealed class RateLimitRegistration -{ - /// - /// CVar that controls the period over which the rate limit is counted, measured in seconds. - /// - public required CVarDef CVarLimitPeriodLength { get; init; } - - /// - /// CVar that controls how many actions are allowed in a single rate limit period. - /// - public required CVarDef CVarLimitCount { get; init; } - - /// - /// An action that gets invoked when this rate limit has been breached by a player. - /// - /// - /// This can be used for informing players or taking administrative action. - /// - public required Action PlayerLimitedAction { get; init; } - - /// - /// CVar that controls the minimum delay between admin notifications, measured in seconds. - /// This can be omitted to have no admin notification system. - /// - /// - /// If set, must be set too. - /// - public CVarDef? CVarAdminAnnounceDelay { get; init; } - - /// - /// An action that gets invoked when a rate limit was breached and admins should be notified. - /// - /// - /// If set, must be set too. - /// - public Action? AdminAnnounceAction { get; init; } - - /// - /// Log type used to log rate limit violations to the admin logs system. - /// - public LogType AdminLogType { get; init; } = LogType.RateLimited; -} - -/// -/// Result of a rate-limited operation. -/// -/// -public enum RateLimitStatus : byte -{ - /// - /// The action was not blocked by the rate limit. - /// - Allowed, - - /// - /// The action was blocked by the rate limit. - /// - Blocked, -} diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index be97dd93a80..14bb760f409 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -906,8 +906,8 @@ public static readonly CVarDef /// After the period has passed, the count resets. /// /// - public static readonly CVarDef AhelpRateLimitPeriod = - CVarDef.Create("ahelp.rate_limit_period", 2, CVar.SERVERONLY); + public static readonly CVarDef AhelpRateLimitPeriod = + CVarDef.Create("ahelp.rate_limit_period", 2f, CVar.SERVERONLY); /// /// How many ahelp messages are allowed in a single rate limit period. @@ -1840,8 +1840,8 @@ public static readonly CVarDef /// After the period has passed, the count resets. /// /// - public static readonly CVarDef ChatRateLimitPeriod = - CVarDef.Create("chat.rate_limit_period", 2, CVar.SERVERONLY); + public static readonly CVarDef ChatRateLimitPeriod = + CVarDef.Create("chat.rate_limit_period", 2f, CVar.SERVERONLY); /// /// How many chat messages are allowed in a single rate limit period. @@ -1851,19 +1851,12 @@ public static readonly CVarDef /// divided by . /// /// - /// public static readonly CVarDef ChatRateLimitCount = CVarDef.Create("chat.rate_limit_count", 10, CVar.SERVERONLY); /// - /// If true, announce when a player breached chat rate limit to game administrators. - /// - /// - public static readonly CVarDef ChatRateLimitAnnounceAdmins = - CVarDef.Create("chat.rate_limit_announce_admins", true, CVar.SERVERONLY); - - /// - /// Minimum delay (in seconds) between announcements from . + /// Minimum delay (in seconds) between notifying admins about chat message rate limit violations. + /// A negative value disables admin announcements. /// public static readonly CVarDef ChatRateLimitAnnounceAdminsDelay = CVarDef.Create("chat.rate_limit_announce_admins_delay", 15, CVar.SERVERONLY); @@ -2059,6 +2052,34 @@ public static readonly CVarDef public static readonly CVarDef ToggleWalk = CVarDef.Create("control.toggle_walk", false, CVar.CLIENTONLY | CVar.ARCHIVE); + /* + * Interactions + */ + + // The rationale behind the default limit is simply that I can easily get to 7 interactions per second by just + // trying to spam toggle a light switch or lever (though the UseDelay component limits the actual effect of the + // interaction). I don't want to accidentally spam admins with alerts just because somebody is spamming a + // key manually, nor do we want to alert them just because the player is having network issues and the server + // receives multiple interactions at once. But we also want to try catch people with modified clients that spam + // many interactions on the same tick. Hence, a very short period, with a relatively high count. + + /// + /// Maximum number of interactions that a player can perform within seconds + /// + public static readonly CVarDef InteractionRateLimitCount = + CVarDef.Create("interaction.rate_limit_count", 5, CVar.SERVER | CVar.REPLICATED); + + /// + public static readonly CVarDef InteractionRateLimitPeriod = + CVarDef.Create("interaction.rate_limit_period", 0.5f, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum delay (in seconds) between notifying admins about interaction rate limit violations. A negative + /// value disables admin announcements. + /// + public static readonly CVarDef InteractionRateLimitAnnounceAdminsDelay = + CVarDef.Create("interaction.rate_limit_announce_admins_delay", 120, CVar.SERVERONLY); + /* * STORAGE */ diff --git a/Content.Shared/Chat/ISharedChatManager.cs b/Content.Shared/Chat/ISharedChatManager.cs new file mode 100644 index 00000000000..39c1d85dd25 --- /dev/null +++ b/Content.Shared/Chat/ISharedChatManager.cs @@ -0,0 +1,8 @@ +namespace Content.Shared.Chat; + +public interface ISharedChatManager +{ + void Initialize(); + void SendAdminAlert(string message); + void SendAdminAlert(EntityUid player, string message); +} diff --git a/Content.Shared/Interaction/SharedInteractionSystem.cs b/Content.Shared/Interaction/SharedInteractionSystem.cs index 8539b9d282b..43dd97762c5 100644 --- a/Content.Shared/Interaction/SharedInteractionSystem.cs +++ b/Content.Shared/Interaction/SharedInteractionSystem.cs @@ -2,6 +2,8 @@ using System.Linq; using Content.Shared.ActionBlocker; using Content.Shared.Administration.Logs; +using Content.Shared.CCVar; +using Content.Shared.Chat; using Content.Shared.CombatMode; using Content.Shared.Database; using Content.Shared.Ghost; @@ -16,8 +18,8 @@ using Content.Shared.Movement.Components; using Content.Shared.Movement.Pulling.Systems; using Content.Shared.Physics; +using Content.Shared.Players.RateLimiting; using Content.Shared.Popups; -using Content.Shared.Silicons.StationAi; using Content.Shared.Storage; using Content.Shared.Tag; using Content.Shared.Timing; @@ -25,6 +27,7 @@ using Content.Shared.Verbs; using Content.Shared.Wall; using JetBrains.Annotations; +using Robust.Shared.Configuration; using Robust.Shared.Containers; using Robust.Shared.Input; using Robust.Shared.Input.Binding; @@ -64,6 +67,9 @@ public abstract partial class SharedInteractionSystem : EntitySystem [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly TagSystem _tagSystem = default!; [Dependency] private readonly SharedUserInterfaceSystem _ui = default!; + [Dependency] private readonly SharedPlayerRateLimitManager _rateLimit = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly ISharedChatManager _chat = default!; private EntityQuery _ignoreUiRangeQuery; private EntityQuery _fixtureQuery; @@ -80,8 +86,8 @@ public abstract partial class SharedInteractionSystem : EntitySystem public const float InteractionRange = 1.5f; public const float InteractionRangeSquared = InteractionRange * InteractionRange; - public const float MaxRaycastRange = 100f; + public const string RateLimitKey = "Interaction"; public delegate bool Ignored(EntityUid entity); @@ -119,9 +125,22 @@ public override void Initialize() new PointerInputCmdHandler(HandleTryPullObject)) .Register(); + _rateLimit.Register(RateLimitKey, + new RateLimitRegistration(CCVars.InteractionRateLimitPeriod, + CCVars.InteractionRateLimitCount, + null, + CCVars.InteractionRateLimitAnnounceAdminsDelay, + RateLimitAlertAdmins) + ); + InitializeBlocking(); } + private void RateLimitAlertAdmins(ICommonSession session) + { + _chat.SendAdminAlert(Loc.GetString("interaction-rate-limit-admin-announcement", ("player", session.Name))); + } + public override void Shutdown() { CommandBinds.Unregister(); @@ -1250,8 +1269,11 @@ public bool CanAccessEquipment(EntityUid user, EntityUid target) return InRangeUnobstructed(user, wearer) && _containerSystem.IsInSameOrParentContainer(user, wearer); } - protected bool ValidateClientInput(ICommonSession? session, EntityCoordinates coords, - EntityUid uid, [NotNullWhen(true)] out EntityUid? userEntity) + protected bool ValidateClientInput( + ICommonSession? session, + EntityCoordinates coords, + EntityUid uid, + [NotNullWhen(true)] out EntityUid? userEntity) { userEntity = null; @@ -1281,7 +1303,7 @@ protected bool ValidateClientInput(ICommonSession? session, EntityCoordinates co return false; } - return true; + return _rateLimit.CountAction(session!, RateLimitKey) == RateLimitStatus.Allowed; } /// diff --git a/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs new file mode 100644 index 00000000000..6bcf15d30b6 --- /dev/null +++ b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs @@ -0,0 +1,76 @@ +using Content.Shared.Database; +using Robust.Shared.Configuration; +using Robust.Shared.Player; + +namespace Content.Shared.Players.RateLimiting; + +/// +/// Contains all data necessary to register a rate limit with . +/// +public sealed class RateLimitRegistration( + CVarDef cVarLimitPeriodLength, + CVarDef cVarLimitCount, + Action? playerLimitedAction, + CVarDef? cVarAdminAnnounceDelay = null, + Action? adminAnnounceAction = null, + LogType adminLogType = LogType.RateLimited) +{ + /// + /// CVar that controls the period over which the rate limit is counted, measured in seconds. + /// + public readonly CVarDef CVarLimitPeriodLength = cVarLimitPeriodLength; + + /// + /// CVar that controls how many actions are allowed in a single rate limit period. + /// + public readonly CVarDef CVarLimitCount = cVarLimitCount; + + /// + /// An action that gets invoked when this rate limit has been breached by a player. + /// + /// + /// This can be used for informing players or taking administrative action. + /// + public readonly Action? PlayerLimitedAction = playerLimitedAction; + + /// + /// CVar that controls the minimum delay between admin notifications, measured in seconds. + /// This can be omitted to have no admin notification system. + /// If the cvar is set to 0, there every breach will be reported. + /// If the cvar is set to a negative number, admin announcements are disabled. + /// + /// + /// If set, must be set too. + /// + public readonly CVarDef? CVarAdminAnnounceDelay = cVarAdminAnnounceDelay; + + /// + /// An action that gets invoked when a rate limit was breached and admins should be notified. + /// + /// + /// If set, must be set too. + /// + public readonly Action? AdminAnnounceAction = adminAnnounceAction; + + /// + /// Log type used to log rate limit violations to the admin logs system. + /// + public readonly LogType AdminLogType = adminLogType; +} + +/// +/// Result of a rate-limited operation. +/// +/// +public enum RateLimitStatus : byte +{ + /// + /// The action was not blocked by the rate limit. + /// + Allowed, + + /// + /// The action was blocked by the rate limit. + /// + Blocked, +} diff --git a/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs b/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs new file mode 100644 index 00000000000..addb1dee373 --- /dev/null +++ b/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs @@ -0,0 +1,55 @@ +using Robust.Shared.Player; + +namespace Content.Shared.Players.RateLimiting; + +/// +/// General-purpose system to rate limit actions taken by clients, such as chat messages. +/// +/// +/// +/// Different categories of rate limits must be registered ahead of time by calling . +/// Once registered, you can simply call to count a rate-limited action for a player. +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +/// +public abstract class SharedPlayerRateLimitManager +{ + /// + /// Count and validate an action performed by a player against rate limits. + /// + /// The player performing the action. + /// The key string that was previously used to register a rate limit category. + /// Whether the action counted should be blocked due to surpassing rate limits or not. + /// + /// is not a connected player + /// OR is not a registered rate limit category. + /// + /// + public abstract RateLimitStatus CountAction(ICommonSession player, string key); + + /// + /// Register a new rate limit category. + /// + /// + /// The key string that will be referred to later with . + /// Must be unique and should probably just be a constant somewhere. + /// + /// The data specifying the rate limit's parameters. + /// has already been registered. + /// is invalid. + public abstract void Register(string key, RateLimitRegistration registration); + + /// + /// Initialize the manager's functionality at game startup. + /// + public abstract void Initialize(); +} diff --git a/Resources/Locale/en-US/interaction/interaction-system.ftl b/Resources/Locale/en-US/interaction/interaction-system.ftl index a4c380abca6..3c0c3ae8b4f 100644 --- a/Resources/Locale/en-US/interaction/interaction-system.ftl +++ b/Resources/Locale/en-US/interaction/interaction-system.ftl @@ -1,2 +1,3 @@ shared-interaction-system-in-range-unobstructed-cannot-reach = You can't reach there! -interaction-system-user-interaction-cannot-reach = You can't reach there! \ No newline at end of file +interaction-system-user-interaction-cannot-reach = You can't reach there! +interaction-rate-limit-admin-announcement = Player { $player } breached interaction rate limits. They may be using macros, auto-clickers, or a modified client. Though they may just be spamming buttons or having network issues.