From da431bbb81efe4fdb548fed1ef9cd7d3a746bf3d 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 f0c73778ec..275589f98c 100644
--- a/Content.Client/Chat/Managers/ChatManager.cs
+++ b/Content.Client/Chat/Managers/ChatManager.cs
@@ -22,6 +22,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();
diff --git a/Content.Client/Chat/Managers/IChatManager.cs b/Content.Client/Chat/Managers/IChatManager.cs
index a21a8194fd..f731798197 100644
--- a/Content.Client/Chat/Managers/IChatManager.cs
+++ b/Content.Client/Chat/Managers/IChatManager.cs
@@ -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);
///
diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs
index 1fd237cf3e..e643552f70 100644
--- a/Content.Client/IoC/ClientContentIoC.cs
+++ b/Content.Client/IoC/ClientContentIoC.cs
@@ -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();
collection.Register();
+ collection.Register();
collection.Register();
collection.Register();
collection.Register();
@@ -47,10 +51,12 @@ namespace Content.Client.IoC
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 0000000000..e79eadd92b
--- /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 bcd48f8238..23f0ded7df 100644
--- a/Content.IntegrationTests/PoolManager.Cvars.cs
+++ b/Content.IntegrationTests/PoolManager.Cvars.cs
@@ -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)
diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs
index 893de4aba5..1efc0a9d56 100644
--- a/Content.Server/Administration/Systems/BwoinkSystem.cs
+++ b/Content.Server/Administration/Systems/BwoinkSystem.cs
@@ -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)
diff --git a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs
index 45e7d2e20d..ccb38166a6 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 @@ 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)
diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs
index 02f718daef..75c46abe37 100644
--- a/Content.Server/Chat/Managers/ChatManager.cs
+++ b/Content.Server/Chat/Managers/ChatManager.cs
@@ -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;
diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs
index 76fa91d847..23211c28fa 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 @@ 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);
diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs
index 453acbbf92..b173dbbdf9 100644
--- a/Content.Server/Chat/Systems/ChatSystem.cs
+++ b/Content.Server/Chat/Systems/ChatSystem.cs
@@ -23,6 +23,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;
diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs
index 3851f145c4..d7f6b85eb6 100644
--- a/Content.Server/IoC/ServerContentIoC.cs
+++ b/Content.Server/IoC/ServerContentIoC.cs
@@ -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();
+ IoCManager.Register();
IoCManager.Register();
IoCManager.Register();
IoCManager.Register();
@@ -68,6 +69,7 @@ namespace Content.Server.IoC
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 59f086f9c3..a3b4d4a536 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 @@ using Robust.Shared.Utility;
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 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;
}
- ///
- /// 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 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);
}
- ///
- /// Initialize the manager's functionality at game startup.
- ///
- public void Initialize()
+ public override void Initialize()
{
_playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged;
}
@@ -189,66 +148,3 @@ public sealed class PlayerRateLimitManager
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 c8b916168a..c5e30ed9ed 100644
--- a/Content.Shared/CCVar/CCVars.cs
+++ b/Content.Shared/CCVar/CCVars.cs
@@ -906,8 +906,8 @@ namespace Content.Shared.CCVar
/// 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 @@ namespace Content.Shared.CCVar
/// 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 @@ namespace Content.Shared.CCVar
/// 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 @@ namespace Content.Shared.CCVar
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 0000000000..39c1d85dd2
--- /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 8539b9d282..43dd97762c 100644
--- a/Content.Shared/Interaction/SharedInteractionSystem.cs
+++ b/Content.Shared/Interaction/SharedInteractionSystem.cs
@@ -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 _ignoreUiRangeQuery;
private EntityQuery _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();
+ _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 @@ 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;
}
///
diff --git a/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs
new file mode 100644
index 0000000000..6bcf15d30b
--- /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 0000000000..addb1dee37
--- /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 a4c380abca..3c0c3ae8b4 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.