using System.Runtime.InteropServices; using Content.Server.Administration.Logs; using Content.Shared.Database; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Player; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Server.Players.RateLimiting; /// /// 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 { [Dependency] private readonly IAdminLogManager _adminLog = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; private readonly Dictionary _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) { if (player.Status == SessionStatus.Disconnected) throw new ArgumentException("Player is not connected"); if (!_registrations.TryGetValue(key, out var registration)) throw new ArgumentException($"Unregistered key: {key}"); var playerData = _rateLimitData.GetOrNew(player); ref var datum = ref CollectionsMarshal.GetValueRefOrAddDefault(playerData, key, out _); var time = _gameTiming.RealTime; if (datum.CountExpires < time) { // Period expired, reset it. datum.CountExpires = time + registration.LimitPeriod; datum.Count = 0; datum.Announced = false; } datum.Count += 1; if (datum.Count <= registration.LimitCount) return RateLimitStatus.Allowed; // Breached rate limits, inform admins if configured. if (registration.AdminAnnounceDelay is { } cvarAnnounceDelay) { if (datum.NextAdminAnnounce < time) { registration.Registration.AdminAnnounceAction!(player); datum.NextAdminAnnounce = time + cvarAnnounceDelay; } } if (!datum.Announced) { registration.Registration.PlayerLimitedAction(player); _adminLog.Add( registration.Registration.AdminLogType, LogImpact.Medium, $"Player {player} breached '{key}' rate limit "); datum.Announced = true; } return RateLimitStatus.Blocked; } /// /// 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) { if (_registrations.ContainsKey(key)) throw new InvalidOperationException($"Key already registered: {key}"); var data = new RegistrationData { Registration = registration, }; if ((registration.AdminAnnounceAction == null) != (registration.CVarAdminAnnounceDelay == null)) { throw new ArgumentException( $"Must set either both {nameof(registration.AdminAnnounceAction)} and {nameof(registration.CVarAdminAnnounceDelay)} or neither"); } _cfg.OnValueChanged( registration.CVarLimitCount, i => data.LimitCount = i, invokeImmediately: true); _cfg.OnValueChanged( registration.CVarLimitPeriodLength, i => data.LimitPeriod = TimeSpan.FromSeconds(i), invokeImmediately: true); if (registration.CVarAdminAnnounceDelay != null) { _cfg.OnValueChanged( registration.CVarLimitCount, i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i), invokeImmediately: true); } _registrations.Add(key, data); } /// /// Initialize the manager's functionality at game startup. /// public void Initialize() { _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged; } private void PlayerManagerOnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) { if (e.NewStatus == SessionStatus.Disconnected) _rateLimitData.Remove(e.Session); } private sealed class RegistrationData { public required RateLimitRegistration Registration { get; init; } public TimeSpan LimitPeriod { get; set; } public int LimitCount { get; set; } public TimeSpan? AdminAnnounceDelay { get; set; } } private struct RateLimitDatum { /// /// Time stamp (relative to ) this rate limit period will expire at. /// public TimeSpan CountExpires; /// /// How many actions have been done in the current rate limit period. /// public int Count; /// /// Have we announced to the player that they've been blocked in this rate limit period? /// public bool Announced; /// /// Time stamp (relative to ) of the /// next time we can send an announcement to admins about rate limit breach. /// 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, }