Add interaction rate limits (#32527)

* Move PlayerRateLimitManager to shared

* Add interaction rate limits

* uncap tests
This commit is contained in:
Leon Friedrich
2024-09-30 01:19:00 +13:00
committed by GitHub
parent 6b49a510d1
commit f1f1fc1dc3
18 changed files with 277 additions and 164 deletions

View File

@@ -21,6 +21,16 @@ internal sealed class ChatManager : IChatManager
_sawmill.Level = LogLevel.Info; _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) public void SendMessage(string text, ChatSelectChannel channel)
{ {
var str = text.ToString(); var str = text.ToString();

View File

@@ -2,10 +2,8 @@ using Content.Shared.Chat;
namespace Content.Client.Chat.Managers namespace Content.Client.Chat.Managers
{ {
public interface IChatManager public interface IChatManager : ISharedChatManager
{ {
void Initialize();
public void SendMessage(string text, ChatSelectChannel channel); public void SendMessage(string text, ChatSelectChannel channel);
} }
} }

View File

@@ -18,8 +18,11 @@ using Content.Client.Viewport;
using Content.Client.Voting; using Content.Client.Voting;
using Content.Shared.Administration.Logs; using Content.Shared.Administration.Logs;
using Content.Client.Lobby; using Content.Client.Lobby;
using Content.Client.Players.RateLimiting;
using Content.Shared.Administration.Managers; using Content.Shared.Administration.Managers;
using Content.Shared.Chat;
using Content.Shared.Players.PlayTimeTracking; using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Players.RateLimiting;
namespace Content.Client.IoC namespace Content.Client.IoC
{ {
@@ -31,6 +34,7 @@ namespace Content.Client.IoC
collection.Register<IParallaxManager, ParallaxManager>(); collection.Register<IParallaxManager, ParallaxManager>();
collection.Register<IChatManager, ChatManager>(); collection.Register<IChatManager, ChatManager>();
collection.Register<ISharedChatManager, ChatManager>();
collection.Register<IClientPreferencesManager, ClientPreferencesManager>(); collection.Register<IClientPreferencesManager, ClientPreferencesManager>();
collection.Register<IStylesheetManager, StylesheetManager>(); collection.Register<IStylesheetManager, StylesheetManager>();
collection.Register<IScreenshotHook, ScreenshotHook>(); collection.Register<IScreenshotHook, ScreenshotHook>();
@@ -47,10 +51,12 @@ namespace Content.Client.IoC
collection.Register<ExtendedDisconnectInformationManager>(); collection.Register<ExtendedDisconnectInformationManager>();
collection.Register<JobRequirementsManager>(); collection.Register<JobRequirementsManager>();
collection.Register<DocumentParsingManager>(); collection.Register<DocumentParsingManager>();
collection.Register<ContentReplayPlaybackManager, ContentReplayPlaybackManager>(); collection.Register<ContentReplayPlaybackManager>();
collection.Register<ISharedPlaytimeManager, JobRequirementsManager>(); collection.Register<ISharedPlaytimeManager, JobRequirementsManager>();
collection.Register<MappingManager>(); collection.Register<MappingManager>();
collection.Register<DebugMonitorManager>(); collection.Register<DebugMonitorManager>();
collection.Register<PlayerRateLimitManager>();
collection.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
} }
} }
} }

View File

@@ -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()
{
}
}

View File

@@ -36,7 +36,9 @@ public static partial class PoolManager
(CCVars.ConfigPresetDevelopment.Name, "false"), (CCVars.ConfigPresetDevelopment.Name, "false"),
(CCVars.AdminLogsEnabled.Name, "false"), (CCVars.AdminLogsEnabled.Name, "false"),
(CCVars.AutosaveEnabled.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) public static async Task SetupCVars(RobustIntegrationTest.IntegrationInstance instance, PoolSettings settings)

View File

@@ -15,6 +15,7 @@ using Content.Shared.Administration;
using Content.Shared.CCVar; using Content.Shared.CCVar;
using Content.Shared.GameTicking; using Content.Shared.GameTicking;
using Content.Shared.Mind; using Content.Shared.Mind;
using Content.Shared.Players.RateLimiting;
using JetBrains.Annotations; using JetBrains.Annotations;
using Robust.Server.Player; using Robust.Server.Player;
using Robust.Shared; using Robust.Shared;
@@ -104,12 +105,10 @@ namespace Content.Server.Administration.Systems
_rateLimit.Register( _rateLimit.Register(
RateLimitKey, RateLimitKey,
new RateLimitRegistration new RateLimitRegistration(CCVars.AhelpRateLimitPeriod,
{ CCVars.AhelpRateLimitCount,
CVarLimitPeriodLength = CCVars.AhelpRateLimitPeriod, PlayerRateLimitedAction)
CVarLimitCount = CCVars.AhelpRateLimitCount, );
PlayerLimitedAction = PlayerRateLimitedAction
});
} }
private void PlayerRateLimitedAction(ICommonSession obj) private void PlayerRateLimitedAction(ICommonSession obj)

View File

@@ -1,6 +1,6 @@
using Content.Server.Players.RateLimiting;
using Content.Shared.CCVar; using Content.Shared.CCVar;
using Content.Shared.Database; using Content.Shared.Database;
using Content.Shared.Players.RateLimiting;
using Robust.Shared.Player; using Robust.Shared.Player;
namespace Content.Server.Chat.Managers; namespace Content.Server.Chat.Managers;
@@ -12,15 +12,13 @@ internal sealed partial class ChatManager
private void RegisterRateLimits() private void RegisterRateLimits()
{ {
_rateLimitManager.Register(RateLimitKey, _rateLimitManager.Register(RateLimitKey,
new RateLimitRegistration new RateLimitRegistration(CCVars.ChatRateLimitPeriod,
{ CCVars.ChatRateLimitCount,
CVarLimitPeriodLength = CCVars.ChatRateLimitPeriod, RateLimitPlayerLimited,
CVarLimitCount = CCVars.ChatRateLimitCount, CCVars.ChatRateLimitAnnounceAdminsDelay,
CVarAdminAnnounceDelay = CCVars.ChatRateLimitAnnounceAdminsDelay, RateLimitAlertAdmins,
PlayerLimitedAction = RateLimitPlayerLimited, LogType.ChatRateLimited)
AdminAnnounceAction = RateLimitAlertAdmins, );
AdminLogType = LogType.ChatRateLimited,
});
} }
private void RateLimitPlayerLimited(ICommonSession player) private void RateLimitPlayerLimited(ICommonSession player)
@@ -30,8 +28,7 @@ internal sealed partial class ChatManager
private void RateLimitAlertAdmins(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) public RateLimitStatus HandleRateLimit(ICommonSession player)

View File

@@ -12,6 +12,7 @@ using Content.Shared.CCVar;
using Content.Shared.Chat; using Content.Shared.Chat;
using Content.Shared.Database; using Content.Shared.Database;
using Content.Shared.Mind; using Content.Shared.Mind;
using Content.Shared.Players.RateLimiting;
using Robust.Shared.Configuration; using Robust.Shared.Configuration;
using Robust.Shared.Network; using Robust.Shared.Network;
using Robust.Shared.Player; using Robust.Shared.Player;

View File

@@ -1,17 +1,14 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Content.Server.Players;
using Content.Server.Players.RateLimiting;
using Content.Shared.Administration; using Content.Shared.Administration;
using Content.Shared.Chat; using Content.Shared.Chat;
using Content.Shared.Players.RateLimiting;
using Robust.Shared.Network; using Robust.Shared.Network;
using Robust.Shared.Player; using Robust.Shared.Player;
namespace Content.Server.Chat.Managers namespace Content.Server.Chat.Managers
{ {
public interface IChatManager public interface IChatManager : ISharedChatManager
{ {
void Initialize();
/// <summary> /// <summary>
/// Dispatch a server announcement to every connected player. /// Dispatch a server announcement to every connected player.
/// </summary> /// </summary>
@@ -26,8 +23,6 @@ namespace Content.Server.Chat.Managers
void SendHookOOC(string sender, string message); void SendHookOOC(string sender, string message);
void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist = null, AdminFlags? flagWhitelist = null); void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist = null, AdminFlags? flagWhitelist = null);
void SendAdminAnnouncementMessage(ICommonSession player, string message, bool suppressLog = true); 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, 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); INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null);

View File

@@ -20,6 +20,7 @@ using Content.Shared.Ghost;
using Content.Shared.IdentityManagement; using Content.Shared.IdentityManagement;
using Content.Shared.Mobs.Systems; using Content.Shared.Mobs.Systems;
using Content.Shared.Players; using Content.Shared.Players;
using Content.Shared.Players.RateLimiting;
using Content.Shared.Radio; using Content.Shared.Radio;
using Content.Shared.Whitelist; using Content.Shared.Whitelist;
using Robust.Server.Player; using Robust.Server.Player;

View File

@@ -14,8 +14,6 @@ using Content.Server.Mapping;
using Content.Server.Maps; using Content.Server.Maps;
using Content.Server.MoMMI; using Content.Server.MoMMI;
using Content.Server.NodeContainer.NodeGroups; using Content.Server.NodeContainer.NodeGroups;
using Content.Server.Objectives;
using Content.Server.Players;
using Content.Server.Players.JobWhitelist; using Content.Server.Players.JobWhitelist;
using Content.Server.Players.PlayTimeTracking; using Content.Server.Players.PlayTimeTracking;
using Content.Server.Players.RateLimiting; using Content.Server.Players.RateLimiting;
@@ -26,8 +24,10 @@ using Content.Server.Voting.Managers;
using Content.Server.Worldgen.Tools; using Content.Server.Worldgen.Tools;
using Content.Shared.Administration.Logs; using Content.Shared.Administration.Logs;
using Content.Shared.Administration.Managers; using Content.Shared.Administration.Managers;
using Content.Shared.Chat;
using Content.Shared.Kitchen; using Content.Shared.Kitchen;
using Content.Shared.Players.PlayTimeTracking; using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Players.RateLimiting;
namespace Content.Server.IoC namespace Content.Server.IoC
{ {
@@ -36,6 +36,7 @@ namespace Content.Server.IoC
public static void Register() public static void Register()
{ {
IoCManager.Register<IChatManager, ChatManager>(); IoCManager.Register<IChatManager, ChatManager>();
IoCManager.Register<ISharedChatManager, ChatManager>();
IoCManager.Register<IChatSanitizationManager, ChatSanitizationManager>(); IoCManager.Register<IChatSanitizationManager, ChatSanitizationManager>();
IoCManager.Register<IMoMMILink, MoMMILink>(); IoCManager.Register<IMoMMILink, MoMMILink>();
IoCManager.Register<IServerPreferencesManager, ServerPreferencesManager>(); IoCManager.Register<IServerPreferencesManager, ServerPreferencesManager>();
@@ -68,6 +69,7 @@ namespace Content.Server.IoC
IoCManager.Register<ServerApi>(); IoCManager.Register<ServerApi>();
IoCManager.Register<JobWhitelistManager>(); IoCManager.Register<JobWhitelistManager>();
IoCManager.Register<PlayerRateLimitManager>(); IoCManager.Register<PlayerRateLimitManager>();
IoCManager.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
IoCManager.Register<MappingManager>(); IoCManager.Register<MappingManager>();
} }
} }

View File

@@ -1,6 +1,7 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Content.Server.Administration.Logs; using Content.Server.Administration.Logs;
using Content.Shared.Database; using Content.Shared.Database;
using Content.Shared.Players.RateLimiting;
using Robust.Server.Player; using Robust.Server.Player;
using Robust.Shared.Configuration; using Robust.Shared.Configuration;
using Robust.Shared.Enums; using Robust.Shared.Enums;
@@ -10,26 +11,7 @@ using Robust.Shared.Utility;
namespace Content.Server.Players.RateLimiting; namespace Content.Server.Players.RateLimiting;
/// <summary> public sealed class PlayerRateLimitManager : SharedPlayerRateLimitManager
/// General-purpose system to rate limit actions taken by clients, such as chat messages.
/// </summary>
/// <remarks>
/// <para>
/// Different categories of rate limits must be registered ahead of time by calling <see cref="Register"/>.
/// Once registered, you can simply call <see cref="CountAction"/> to count a rate-limited action for a player.
/// </para>
/// <para>
/// This system is intended for rate limiting player actions over short periods,
/// to ward against spam that can cause technical issues such as admin client load.
/// It should not be used for in-game actions or similar.
/// </para>
/// <para>
/// Rate limits are reset when a client reconnects.
/// This should not be an issue for the reasonably short rate limit periods this system is intended for.
/// </para>
/// </remarks>
/// <seealso cref="RateLimitRegistration"/>
public sealed class PlayerRateLimitManager
{ {
[Dependency] private readonly IAdminLogManager _adminLog = default!; [Dependency] private readonly IAdminLogManager _adminLog = default!;
[Dependency] private readonly IGameTiming _gameTiming = 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<string, RegistrationData> _registrations = new();
private readonly Dictionary<ICommonSession, Dictionary<string, RateLimitDatum>> _rateLimitData = new(); private readonly Dictionary<ICommonSession, Dictionary<string, RateLimitDatum>> _rateLimitData = new();
/// <summary> public override RateLimitStatus CountAction(ICommonSession player, string key)
/// Count and validate an action performed by a player against rate limits.
/// </summary>
/// <param name="player">The player performing the action.</param>
/// <param name="key">The key string that was previously used to register a rate limit category.</param>
/// <returns>Whether the action counted should be blocked due to surpassing rate limits or not.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="player"/> is not a connected player
/// OR <paramref name="key"/> is not a registered rate limit category.
/// </exception>
/// <seealso cref="Register"/>
public RateLimitStatus CountAction(ICommonSession player, string key)
{ {
if (player.Status == SessionStatus.Disconnected) if (player.Status == SessionStatus.Disconnected)
throw new ArgumentException("Player is not connected"); throw new ArgumentException("Player is not connected");
@@ -74,7 +45,8 @@ public sealed class PlayerRateLimitManager
return RateLimitStatus.Allowed; return RateLimitStatus.Allowed;
// Breached rate limits, inform admins if configured. // 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) if (datum.NextAdminAnnounce < time)
{ {
@@ -85,7 +57,7 @@ public sealed class PlayerRateLimitManager
if (!datum.Announced) if (!datum.Announced)
{ {
registration.Registration.PlayerLimitedAction(player); registration.Registration.PlayerLimitedAction?.Invoke(player);
_adminLog.Add( _adminLog.Add(
registration.Registration.AdminLogType, registration.Registration.AdminLogType,
LogImpact.Medium, LogImpact.Medium,
@@ -97,17 +69,7 @@ public sealed class PlayerRateLimitManager
return RateLimitStatus.Blocked; return RateLimitStatus.Blocked;
} }
/// <summary> public override void Register(string key, RateLimitRegistration registration)
/// Register a new rate limit category.
/// </summary>
/// <param name="key">
/// The key string that will be referred to later with <see cref="CountAction"/>.
/// Must be unique and should probably just be a constant somewhere.
/// </param>
/// <param name="registration">The data specifying the rate limit's parameters.</param>
/// <exception cref="InvalidOperationException"><paramref name="key"/> has already been registered.</exception>
/// <exception cref="ArgumentException"><paramref name="registration"/> is invalid.</exception>
public void Register(string key, RateLimitRegistration registration)
{ {
if (_registrations.ContainsKey(key)) if (_registrations.ContainsKey(key))
throw new InvalidOperationException($"Key already registered: {key}"); throw new InvalidOperationException($"Key already registered: {key}");
@@ -135,7 +97,7 @@ public sealed class PlayerRateLimitManager
if (registration.CVarAdminAnnounceDelay != null) if (registration.CVarAdminAnnounceDelay != null)
{ {
_cfg.OnValueChanged( _cfg.OnValueChanged(
registration.CVarLimitCount, registration.CVarAdminAnnounceDelay,
i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i), i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i),
invokeImmediately: true); invokeImmediately: true);
} }
@@ -143,10 +105,7 @@ public sealed class PlayerRateLimitManager
_registrations.Add(key, data); _registrations.Add(key, data);
} }
/// <summary> public override void Initialize()
/// Initialize the manager's functionality at game startup.
/// </summary>
public void Initialize()
{ {
_playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged; _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged;
} }
@@ -189,66 +148,3 @@ public sealed class PlayerRateLimitManager
public TimeSpan NextAdminAnnounce; public TimeSpan NextAdminAnnounce;
} }
} }
/// <summary>
/// Contains all data necessary to register a rate limit with <see cref="PlayerRateLimitManager.Register"/>.
/// </summary>
public sealed class RateLimitRegistration
{
/// <summary>
/// CVar that controls the period over which the rate limit is counted, measured in seconds.
/// </summary>
public required CVarDef<int> CVarLimitPeriodLength { get; init; }
/// <summary>
/// CVar that controls how many actions are allowed in a single rate limit period.
/// </summary>
public required CVarDef<int> CVarLimitCount { get; init; }
/// <summary>
/// An action that gets invoked when this rate limit has been breached by a player.
/// </summary>
/// <remarks>
/// This can be used for informing players or taking administrative action.
/// </remarks>
public required Action<ICommonSession> PlayerLimitedAction { get; init; }
/// <summary>
/// CVar that controls the minimum delay between admin notifications, measured in seconds.
/// This can be omitted to have no admin notification system.
/// </summary>
/// <remarks>
/// If set, <see cref="AdminAnnounceAction"/> must be set too.
/// </remarks>
public CVarDef<int>? CVarAdminAnnounceDelay { get; init; }
/// <summary>
/// An action that gets invoked when a rate limit was breached and admins should be notified.
/// </summary>
/// <remarks>
/// If set, <see cref="CVarAdminAnnounceDelay"/> must be set too.
/// </remarks>
public Action<ICommonSession>? AdminAnnounceAction { get; init; }
/// <summary>
/// Log type used to log rate limit violations to the admin logs system.
/// </summary>
public LogType AdminLogType { get; init; } = LogType.RateLimited;
}
/// <summary>
/// Result of a rate-limited operation.
/// </summary>
/// <seealso cref="PlayerRateLimitManager.CountAction"/>
public enum RateLimitStatus : byte
{
/// <summary>
/// The action was not blocked by the rate limit.
/// </summary>
Allowed,
/// <summary>
/// The action was blocked by the rate limit.
/// </summary>
Blocked,
}

View File

@@ -906,8 +906,8 @@ namespace Content.Shared.CCVar
/// After the period has passed, the count resets. /// After the period has passed, the count resets.
/// </summary> /// </summary>
/// <seealso cref="AhelpRateLimitCount"/> /// <seealso cref="AhelpRateLimitCount"/>
public static readonly CVarDef<int> AhelpRateLimitPeriod = public static readonly CVarDef<float> AhelpRateLimitPeriod =
CVarDef.Create("ahelp.rate_limit_period", 2, CVar.SERVERONLY); CVarDef.Create("ahelp.rate_limit_period", 2f, CVar.SERVERONLY);
/// <summary> /// <summary>
/// How many ahelp messages are allowed in a single rate limit period. /// 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. /// After the period has passed, the count resets.
/// </summary> /// </summary>
/// <seealso cref="ChatRateLimitCount"/> /// <seealso cref="ChatRateLimitCount"/>
public static readonly CVarDef<int> ChatRateLimitPeriod = public static readonly CVarDef<float> ChatRateLimitPeriod =
CVarDef.Create("chat.rate_limit_period", 2, CVar.SERVERONLY); CVarDef.Create("chat.rate_limit_period", 2f, CVar.SERVERONLY);
/// <summary> /// <summary>
/// How many chat messages are allowed in a single rate limit period. /// 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"/>. /// <see cref="ChatRateLimitCount"/> divided by <see cref="ChatRateLimitCount"/>.
/// </remarks> /// </remarks>
/// <seealso cref="ChatRateLimitPeriod"/> /// <seealso cref="ChatRateLimitPeriod"/>
/// <seealso cref="ChatRateLimitAnnounceAdmins"/>
public static readonly CVarDef<int> ChatRateLimitCount = public static readonly CVarDef<int> ChatRateLimitCount =
CVarDef.Create("chat.rate_limit_count", 10, CVar.SERVERONLY); CVarDef.Create("chat.rate_limit_count", 10, CVar.SERVERONLY);
/// <summary> /// <summary>
/// If true, announce when a player breached chat rate limit to game administrators. /// Minimum delay (in seconds) between notifying admins about chat message rate limit violations.
/// </summary> /// A negative value disables admin announcements.
/// <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"/>.
/// </summary> /// </summary>
public static readonly CVarDef<int> ChatRateLimitAnnounceAdminsDelay = public static readonly CVarDef<int> ChatRateLimitAnnounceAdminsDelay =
CVarDef.Create("chat.rate_limit_announce_admins_delay", 15, CVar.SERVERONLY); 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 = public static readonly CVarDef<bool> ToggleWalk =
CVarDef.Create("control.toggle_walk", false, CVar.CLIENTONLY | CVar.ARCHIVE); 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 * STORAGE
*/ */

View File

@@ -0,0 +1,8 @@
namespace Content.Shared.Chat;
public interface ISharedChatManager
{
void Initialize();
void SendAdminAlert(string message);
void SendAdminAlert(EntityUid player, string message);
}

View File

@@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using Content.Shared.ActionBlocker; using Content.Shared.ActionBlocker;
using Content.Shared.Administration.Logs; using Content.Shared.Administration.Logs;
using Content.Shared.CCVar;
using Content.Shared.Chat;
using Content.Shared.CombatMode; using Content.Shared.CombatMode;
using Content.Shared.Database; using Content.Shared.Database;
using Content.Shared.Ghost; using Content.Shared.Ghost;
@@ -16,8 +18,8 @@ using Content.Shared.Item;
using Content.Shared.Movement.Components; using Content.Shared.Movement.Components;
using Content.Shared.Movement.Pulling.Systems; using Content.Shared.Movement.Pulling.Systems;
using Content.Shared.Physics; using Content.Shared.Physics;
using Content.Shared.Players.RateLimiting;
using Content.Shared.Popups; using Content.Shared.Popups;
using Content.Shared.Silicons.StationAi;
using Content.Shared.Storage; using Content.Shared.Storage;
using Content.Shared.Tag; using Content.Shared.Tag;
using Content.Shared.Timing; using Content.Shared.Timing;
@@ -25,6 +27,7 @@ using Content.Shared.UserInterface;
using Content.Shared.Verbs; using Content.Shared.Verbs;
using Content.Shared.Wall; using Content.Shared.Wall;
using JetBrains.Annotations; using JetBrains.Annotations;
using Robust.Shared.Configuration;
using Robust.Shared.Containers; using Robust.Shared.Containers;
using Robust.Shared.Input; using Robust.Shared.Input;
using Robust.Shared.Input.Binding; using Robust.Shared.Input.Binding;
@@ -64,6 +67,9 @@ namespace Content.Shared.Interaction
[Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly TagSystem _tagSystem = default!; [Dependency] private readonly TagSystem _tagSystem = default!;
[Dependency] private readonly SharedUserInterfaceSystem _ui = 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<IgnoreUIRangeComponent> _ignoreUiRangeQuery;
private EntityQuery<FixturesComponent> _fixtureQuery; private EntityQuery<FixturesComponent> _fixtureQuery;
@@ -80,8 +86,8 @@ namespace Content.Shared.Interaction
public const float InteractionRange = 1.5f; public const float InteractionRange = 1.5f;
public const float InteractionRangeSquared = InteractionRange * InteractionRange; public const float InteractionRangeSquared = InteractionRange * InteractionRange;
public const float MaxRaycastRange = 100f; public const float MaxRaycastRange = 100f;
public const string RateLimitKey = "Interaction";
public delegate bool Ignored(EntityUid entity); public delegate bool Ignored(EntityUid entity);
@@ -119,9 +125,22 @@ namespace Content.Shared.Interaction
new PointerInputCmdHandler(HandleTryPullObject)) new PointerInputCmdHandler(HandleTryPullObject))
.Register<SharedInteractionSystem>(); .Register<SharedInteractionSystem>();
_rateLimit.Register(RateLimitKey,
new RateLimitRegistration(CCVars.InteractionRateLimitPeriod,
CCVars.InteractionRateLimitCount,
null,
CCVars.InteractionRateLimitAnnounceAdminsDelay,
RateLimitAlertAdmins)
);
InitializeBlocking(); InitializeBlocking();
} }
private void RateLimitAlertAdmins(ICommonSession session)
{
_chat.SendAdminAlert(Loc.GetString("interaction-rate-limit-admin-announcement", ("player", session.Name)));
}
public override void Shutdown() public override void Shutdown()
{ {
CommandBinds.Unregister<SharedInteractionSystem>(); CommandBinds.Unregister<SharedInteractionSystem>();
@@ -1250,8 +1269,11 @@ namespace Content.Shared.Interaction
return InRangeUnobstructed(user, wearer) && _containerSystem.IsInSameOrParentContainer(user, wearer); return InRangeUnobstructed(user, wearer) && _containerSystem.IsInSameOrParentContainer(user, wearer);
} }
protected bool ValidateClientInput(ICommonSession? session, EntityCoordinates coords, protected bool ValidateClientInput(
EntityUid uid, [NotNullWhen(true)] out EntityUid? userEntity) ICommonSession? session,
EntityCoordinates coords,
EntityUid uid,
[NotNullWhen(true)] out EntityUid? userEntity)
{ {
userEntity = null; userEntity = null;
@@ -1281,7 +1303,7 @@ namespace Content.Shared.Interaction
return false; return false;
} }
return true; return _rateLimit.CountAction(session!, RateLimitKey) == RateLimitStatus.Allowed;
} }
/// <summary> /// <summary>

View File

@@ -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,
}

View File

@@ -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();
}

View File

@@ -1,2 +1,3 @@
shared-interaction-system-in-range-unobstructed-cannot-reach = You can't reach there! shared-interaction-system-in-range-unobstructed-cannot-reach = You can't reach there!
interaction-system-user-interaction-cannot-reach = You can't reach there! 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.