]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add interaction rate limits (#32527)
authorLeon Friedrich <60421075+ElectroJr@users.noreply.github.com>
Sun, 29 Sep 2024 12:19:00 +0000 (01:19 +1300)
committerGitHub <noreply@github.com>
Sun, 29 Sep 2024 12:19:00 +0000 (22:19 +1000)
* Move PlayerRateLimitManager to shared

* Add interaction rate limits

* uncap tests

18 files changed:
Content.Client/Chat/Managers/ChatManager.cs
Content.Client/Chat/Managers/IChatManager.cs
Content.Client/IoC/ClientContentIoC.cs
Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs [new file with mode: 0644]
Content.IntegrationTests/PoolManager.Cvars.cs
Content.Server/Administration/Systems/BwoinkSystem.cs
Content.Server/Chat/Managers/ChatManager.RateLimit.cs
Content.Server/Chat/Managers/ChatManager.cs
Content.Server/Chat/Managers/IChatManager.cs
Content.Server/Chat/Systems/ChatSystem.cs
Content.Server/IoC/ServerContentIoC.cs
Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs
Content.Shared/CCVar/CCVars.cs
Content.Shared/Chat/ISharedChatManager.cs [new file with mode: 0644]
Content.Shared/Interaction/SharedInteractionSystem.cs
Content.Shared/Players/RateLimiting/RateLimitRegistration.cs [new file with mode: 0644]
Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs [new file with mode: 0644]
Resources/Locale/en-US/interaction/interaction-system.ftl

index e428d30f20c702374dba8ea155ccf750baff315f..68707e021c5c6a0f42c2fcb6e677d4836f39ee80 100644 (file)
@@ -21,6 +21,16 @@ internal sealed class ChatManager : IChatManager
         _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();
index 6464ca1019615c1a8b8258bef854a1e112407fac..62a97c6bd82b222a8f02c10de54f94218d9255fb 100644 (file)
@@ -2,10 +2,8 @@ using Content.Shared.Chat;
 
 namespace Content.Client.Chat.Managers
 {
-    public interface IChatManager
+    public interface IChatManager : ISharedChatManager
     {
-        void Initialize();
-
         public void SendMessage(string text, ChatSelectChannel channel);
     }
 }
index 1fd237cf3e32fb2adebe0b4bbc2e53b41f0c758b..e643552f70b3b12ca6c06782b0268aab7f7e2fef 100644 (file)
@@ -18,8 +18,11 @@ using Content.Client.Viewport;
 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 @@ namespace Content.Client.IoC
 
             collection.Register<IParallaxManager, ParallaxManager>();
             collection.Register<IChatManager, ChatManager>();
+            collection.Register<ISharedChatManager, ChatManager>();
             collection.Register<IClientPreferencesManager, ClientPreferencesManager>();
             collection.Register<IStylesheetManager, StylesheetManager>();
             collection.Register<IScreenshotHook, ScreenshotHook>();
@@ -47,10 +51,12 @@ namespace Content.Client.IoC
             collection.Register<ExtendedDisconnectInformationManager>();
             collection.Register<JobRequirementsManager>();
             collection.Register<DocumentParsingManager>();
-            collection.Register<ContentReplayPlaybackManager, ContentReplayPlaybackManager>();
+            collection.Register<ContentReplayPlaybackManager>();
             collection.Register<ISharedPlaytimeManager, JobRequirementsManager>();
             collection.Register<MappingManager>();
             collection.Register<DebugMonitorManager>();
+            collection.Register<PlayerRateLimitManager>();
+            collection.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
         }
     }
 }
diff --git a/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs
new file mode 100644 (file)
index 0000000..e79eadd
--- /dev/null
@@ -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()
+    {
+    }
+}
index bcd48f82380579c1426a49654550a80aa85becf9..23f0ded7df2afd506d994cef35b432b0db109789 100644 (file)
@@ -36,7 +36,9 @@ public static partial class PoolManager
         (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)
index 893de4aba5b7c27340acaf9b144af46c7f96713b..1efc0a9d5620a15780f7a4eff06f2601da1ac75f 100644 (file)
@@ -15,6 +15,7 @@ using Content.Shared.Administration;
 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 @@ namespace Content.Server.Administration.Systems
 
                _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)
index 45e7d2e20d0f26c732eb95a5ac5185d001e7adce..ccb38166a6d9c30673da7a1771884242df44021c 100644 (file)
@@ -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 @@ internal sealed partial class ChatManager
 
     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)
index 02f718daef0e6ee5af351704634a31b59820d989..75c46abe37b295141c99c53286445196b8bbd1fc 100644 (file)
@@ -12,6 +12,7 @@ using Content.Shared.CCVar;
 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;
index 76fa91d847411e89ef3540e406b15f3033552785..23211c28fa09bd5e528a932dc9d9b5832122f387 100644 (file)
@@ -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();
-
         /// <summary>
         ///     Dispatch a server announcement to every connected player.
         /// </summary>
@@ -26,8 +23,6 @@ namespace Content.Server.Chat.Managers
         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);
index 24937ea4b9f423150a7d6e71c6ecf4afee1470a9..624c18130b0c2ace19ded31cf4f8783a9d86d35a 100644 (file)
@@ -20,6 +20,7 @@ using Content.Shared.Ghost;
 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;
index 3851f145c401352641c8eb11a95c1b26e0e46d15..d7f6b85eb6000a61b823d17dda4ba261168c19dc 100644 (file)
@@ -14,8 +14,6 @@ using Content.Server.Mapping;
 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.Voting.Managers;
 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 @@ namespace Content.Server.IoC
         public static void Register()
         {
             IoCManager.Register<IChatManager, ChatManager>();
+            IoCManager.Register<ISharedChatManager, ChatManager>();
             IoCManager.Register<IChatSanitizationManager, ChatSanitizationManager>();
             IoCManager.Register<IMoMMILink, MoMMILink>();
             IoCManager.Register<IServerPreferencesManager, ServerPreferencesManager>();
@@ -68,6 +69,7 @@ namespace Content.Server.IoC
             IoCManager.Register<ServerApi>();
             IoCManager.Register<JobWhitelistManager>();
             IoCManager.Register<PlayerRateLimitManager>();
+            IoCManager.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
             IoCManager.Register<MappingManager>();
         }
     }
index 59f086f9c315706ede624d36ac14357c309278f5..a3b4d4a5364fd4194b95b4b4de372b4046b25994 100644 (file)
@@ -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 @@ 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
+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<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)
+    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 sealed class PlayerRateLimitManager
             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 sealed class PlayerRateLimitManager
 
         if (!datum.Announced)
         {
-            registration.Registration.PlayerLimitedAction(player);
+            registration.Registration.PlayerLimitedAction?.Invoke(player);
             _adminLog.Add(
                 registration.Registration.AdminLogType,
                 LogImpact.Medium,
@@ -97,17 +69,7 @@ public sealed class PlayerRateLimitManager
         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)
+    public override void Register(string key, RateLimitRegistration registration)
     {
         if (_registrations.ContainsKey(key))
             throw new InvalidOperationException($"Key already registered: {key}");
@@ -135,7 +97,7 @@ public sealed class PlayerRateLimitManager
         if (registration.CVarAdminAnnounceDelay != null)
         {
             _cfg.OnValueChanged(
-                registration.CVarLimitCount,
+                registration.CVarAdminAnnounceDelay,
                 i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i),
                 invokeImmediately: true);
         }
@@ -143,10 +105,7 @@ public sealed class PlayerRateLimitManager
         _registrations.Add(key, data);
     }
 
-    /// <summary>
-    /// Initialize the manager's functionality at game startup.
-    /// </summary>
-    public void Initialize()
+    public override void Initialize()
     {
         _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged;
     }
@@ -189,66 +148,3 @@ public sealed class PlayerRateLimitManager
         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,
-}
index be97dd93a80452b1844b89c2f45f6caa53876602..14bb760f409af6494e9331ebc1ad597999303c67 100644 (file)
@@ -906,8 +906,8 @@ namespace Content.Shared.CCVar
         /// 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);
+        public static readonly CVarDef<float> AhelpRateLimitPeriod =
+            CVarDef.Create("ahelp.rate_limit_period", 2f, CVar.SERVERONLY);
 
         /// <summary>
         /// How many ahelp messages are allowed in a single rate limit period.
@@ -1840,8 +1840,8 @@ namespace Content.Shared.CCVar
         /// After the period has passed, the count resets.
         /// </summary>
         /// <seealso cref="ChatRateLimitCount"/>
-        public static readonly CVarDef<int> ChatRateLimitPeriod =
-            CVarDef.Create("chat.rate_limit_period", 2, CVar.SERVERONLY);
+        public static readonly CVarDef<float> ChatRateLimitPeriod =
+            CVarDef.Create("chat.rate_limit_period", 2f, CVar.SERVERONLY);
 
         /// <summary>
         /// How many chat messages are allowed in a single rate limit period.
@@ -1851,19 +1851,12 @@ namespace Content.Shared.CCVar
         /// <see cref="ChatRateLimitCount"/> divided by <see cref="ChatRateLimitCount"/>.
         /// </remarks>
         /// <seealso cref="ChatRateLimitPeriod"/>
-        /// <seealso cref="ChatRateLimitAnnounceAdmins"/>
         public static readonly CVarDef<int> ChatRateLimitCount =
             CVarDef.Create("chat.rate_limit_count", 10, CVar.SERVERONLY);
 
         /// <summary>
-        /// If true, announce when a player breached chat rate limit to game administrators.
-        /// </summary>
-        /// <seealso cref="ChatRateLimitAnnounceAdminsDelay"/>
-        public static readonly CVarDef<bool> ChatRateLimitAnnounceAdmins =
-            CVarDef.Create("chat.rate_limit_announce_admins", true, CVar.SERVERONLY);
-
-        /// <summary>
-        /// Minimum delay (in seconds) between announcements from <see cref="ChatRateLimitAnnounceAdmins"/>.
+        /// Minimum delay (in seconds) between notifying admins about chat message rate limit violations.
+        /// A negative value disables admin announcements.
         /// </summary>
         public static readonly CVarDef<int> ChatRateLimitAnnounceAdminsDelay =
             CVarDef.Create("chat.rate_limit_announce_admins_delay", 15, CVar.SERVERONLY);
@@ -2059,6 +2052,34 @@ namespace Content.Shared.CCVar
         public static readonly CVarDef<bool> 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.
+
+        /// <summary>
+        /// Maximum number of interactions that a player can perform within <see cref="InteractionRateLimitCount"/> seconds
+        /// </summary>
+        public static readonly CVarDef<int> InteractionRateLimitCount =
+            CVarDef.Create("interaction.rate_limit_count", 5, CVar.SERVER | CVar.REPLICATED);
+
+        /// <seealso cref="InteractionRateLimitCount"/>
+        public static readonly CVarDef<float> InteractionRateLimitPeriod =
+            CVarDef.Create("interaction.rate_limit_period", 0.5f, CVar.SERVER | CVar.REPLICATED);
+
+        /// <summary>
+        /// Minimum delay (in seconds) between notifying admins about interaction rate limit violations. A negative
+        /// value disables admin announcements.
+        /// </summary>
+        public static readonly CVarDef<int> 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 (file)
index 0000000..39c1d85
--- /dev/null
@@ -0,0 +1,8 @@
+namespace Content.Shared.Chat;
+
+public interface ISharedChatManager
+{
+    void Initialize();
+    void SendAdminAlert(string message);
+    void SendAdminAlert(EntityUid player, string message);
+}
index 8539b9d282b8fdfa10722fd85b5398bedb0381a2..43dd97762c5e3cad41db226fcd3721617b666c08 100644 (file)
@@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis;
 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.Item;
 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.UserInterface;
 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 @@ namespace Content.Shared.Interaction
         [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<IgnoreUIRangeComponent> _ignoreUiRangeQuery;
         private EntityQuery<FixturesComponent> _fixtureQuery;
@@ -80,8 +86,8 @@ namespace Content.Shared.Interaction
 
         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 @@ namespace Content.Shared.Interaction
                     new PointerInputCmdHandler(HandleTryPullObject))
                 .Register<SharedInteractionSystem>();
 
+            _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<SharedInteractionSystem>();
@@ -1250,8 +1269,11 @@ namespace Content.Shared.Interaction
             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 @@ namespace Content.Shared.Interaction
                 return false;
             }
 
-            return true;
+            return _rateLimit.CountAction(session!, RateLimitKey) == RateLimitStatus.Allowed;
         }
 
         /// <summary>
diff --git a/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs
new file mode 100644 (file)
index 0000000..6bcf15d
--- /dev/null
@@ -0,0 +1,76 @@
+using Content.Shared.Database;
+using Robust.Shared.Configuration;
+using Robust.Shared.Player;
+
+namespace Content.Shared.Players.RateLimiting;
+
+/// <summary>
+/// Contains all data necessary to register a rate limit with <see cref="SharedPlayerRateLimitManager.Register"/>.
+/// </summary>
+public sealed class RateLimitRegistration(
+    CVarDef<float> cVarLimitPeriodLength,
+    CVarDef<int> cVarLimitCount,
+    Action<ICommonSession>? playerLimitedAction,
+    CVarDef<int>? cVarAdminAnnounceDelay = null,
+    Action<ICommonSession>? adminAnnounceAction = null,
+    LogType adminLogType = LogType.RateLimited)
+{
+    /// <summary>
+    /// CVar that controls the period over which the rate limit is counted, measured in seconds.
+    /// </summary>
+    public readonly CVarDef<float> CVarLimitPeriodLength = cVarLimitPeriodLength;
+
+    /// <summary>
+    /// CVar that controls how many actions are allowed in a single rate limit period.
+    /// </summary>
+    public readonly CVarDef<int> CVarLimitCount = cVarLimitCount;
+
+    /// <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 readonly Action<ICommonSession>? PlayerLimitedAction = playerLimitedAction;
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    /// <remarks>
+    /// If set, <see cref="AdminAnnounceAction"/> must be set too.
+    /// </remarks>
+    public readonly CVarDef<int>? CVarAdminAnnounceDelay = cVarAdminAnnounceDelay;
+
+    /// <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 readonly Action<ICommonSession>? AdminAnnounceAction = adminAnnounceAction;
+
+    /// <summary>
+    /// Log type used to log rate limit violations to the admin logs system.
+    /// </summary>
+    public readonly LogType AdminLogType = adminLogType;
+}
+
+/// <summary>
+/// Result of a rate-limited operation.
+/// </summary>
+/// <seealso cref="SharedPlayerRateLimitManager.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,
+}
diff --git a/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs b/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs
new file mode 100644 (file)
index 0000000..addb1de
--- /dev/null
@@ -0,0 +1,55 @@
+using Robust.Shared.Player;
+
+namespace Content.Shared.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 abstract class SharedPlayerRateLimitManager
+{
+    /// <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 abstract RateLimitStatus CountAction(ICommonSession player, string key);
+
+    /// <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 abstract void Register(string key, RateLimitRegistration registration);
+
+    /// <summary>
+    /// Initialize the manager's functionality at game startup.
+    /// </summary>
+    public abstract void Initialize();
+}
index a4c380abca62847d94c1cd32fcdbce5f3e8361ee..3c0c3ae8b4f2a3e4e2a5dd22d07dd2257a3f900c 100644 (file)
@@ -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.