using System.Buffers; using System.Net; using System.Security.Cryptography; using System.Text; using FinalFactory.Rendezvous.Server.Observability; using Microsoft.Extensions.Options; namespace FinalFactory.Rendezvous.Server.Abuse; internal sealed class AbuseProtectionService { private readonly AbuseProtectionOptions _options; private readonly TimeProvider _timeProvider; private readonly TrackerState _httpTracker; private readonly TrackerState _udpTracker; private readonly RendezvousTelemetry? _telemetry; private readonly HashSet _operatorAllowedAddresses; public AbuseProtectionService( IOptions options, TimeProvider? timeProvider = null, RendezvousTelemetry? telemetry = null) { _options = options.Value; _timeProvider = timeProvider ?? TimeProvider.System; _telemetry = telemetry; _operatorAllowedAddresses = options.Value.OperatorAllowedAddresses .Select(static value => IPAddress.TryParse(value, out IPAddress? address) ? NormalizeAddress(address).ToString() : string.Empty) .Where(static value => value.Length > 0) .ToHashSet(StringComparer.Ordinal); DateTimeOffset now = _timeProvider.GetUtcNow(); _httpTracker = new(now); _udpTracker = new(now); } public bool TryAcquireHttpIngress( IPAddress? remoteAddress, string operation, out AbuseLease? lease, out int retryAfterSeconds) { string prefix = GetNetworkPrefix(remoteAddress); List rates = [ new("http:rate:global", _options.HttpGlobalRequestsPerWindow), new($"http:rate:ip:{prefix}", _options.HttpIpPrefixRequestsPerWindow), new($"http:rate:operation:{operation}", _options.HttpOperationRequestsPerWindow), ]; List concurrency = [ new("http:concurrency:global", _options.HttpGlobalConcurrency), new($"http:concurrency:ip:{prefix}", _options.HttpIpPrefixConcurrency), new($"http:concurrency:operation:{operation}", _options.HttpOperationConcurrency), ]; if (!IsLeaseCriticalOperation(operation)) { rates.Add(new("http:rate:optional", _options.HttpOptionalRequestsPerWindow)); rates.Add(new($"http:rate:optional-ip:{prefix}", _options.HttpOptionalIpPrefixRequestsPerWindow)); concurrency.Add(new("http:concurrency:optional", _options.HttpOptionalConcurrency)); concurrency.Add(new($"http:concurrency:optional-ip:{prefix}", _options.HttpOptionalIpPrefixConcurrency)); } return TryAcquire( [.. rates], [.. concurrency], TrackerDomain.Http, IsLeaseCriticalOperation(operation), out lease, out retryAfterSeconds); } public bool TryAcquireHealthIngress( IPAddress? remoteAddress, out AbuseLease? lease, out int retryAfterSeconds) { string prefix = GetNetworkPrefix(remoteAddress); RateDimension[] rates = [ new("health:rate:global", _options.HealthGlobalRequestsPerWindow), new($"health:rate:ip:{prefix}", _options.HealthIpPrefixRequestsPerWindow), ]; RateDimension[] concurrency = [ new("health:concurrency:global", _options.HealthGlobalConcurrency), new($"health:concurrency:ip:{prefix}", _options.HealthIpPrefixConcurrency), ]; return TryAcquire( rates, concurrency, TrackerDomain.Http, true, out lease, out retryAfterSeconds); } public bool IsOperatorSourceAllowed(IPAddress? remoteAddress) => remoteAddress is not null && _operatorAllowedAddresses.Contains(NormalizeAddress(remoteAddress).ToString()); public bool TryAcquireOperatorIngress( IPAddress? remoteAddress, out AbuseLease? lease, out int retryAfterSeconds) { string prefix = GetNetworkPrefix(remoteAddress); RateDimension[] rates = [ new("operator:rate:global", _options.OperatorGlobalRequestsPerWindow), new($"operator:rate:ip:{prefix}", _options.OperatorIpPrefixRequestsPerWindow), ]; RateDimension[] concurrency = [ new("operator:concurrency:global", _options.OperatorGlobalConcurrency), new($"operator:concurrency:ip:{prefix}", _options.OperatorIpPrefixConcurrency), ]; return TryAcquire( rates, concurrency, TrackerDomain.Http, true, out lease, out retryAfterSeconds); } public bool TryAcquireHttpIdentity( string operation, string? tenant, string? principal, string? resource, out AbuseLease? lease, out int retryAfterSeconds) => TryAcquireHttpIdentity( operation, null, tenant, principal, resource, out lease, out retryAfterSeconds); public bool TryAcquireHttpIdentity( string operation, IPAddress? remoteAddress, string? tenant, string? principal, string? resource, out AbuseLease? lease, out int retryAfterSeconds) { string sourcePrefix = GetNetworkPrefix(remoteAddress); List rates = []; List concurrency = []; AddDimension(rates, concurrency, "tenant", tenant, _options.HttpTenantRequestsPerWindow, _options.HttpTenantConcurrency); AddDimension(rates, concurrency, "principal", principal, _options.HttpPrincipalRequestsPerWindow, _options.HttpPrincipalConcurrency); AddDimension(rates, concurrency, "resource", resource, _options.HttpResourceRequestsPerWindow, _options.HttpResourceConcurrency); return TryAcquire( [.. rates], [.. concurrency], TrackerDomain.Http, IsLeaseCriticalOperation(operation), out lease, out retryAfterSeconds); void AddDimension( List rateDimensions, List concurrencyDimensions, string kind, string? value, int rateLimit, int concurrencyLimit) { if (string.IsNullOrEmpty(value)) { return; } if (kind == "resource") { string validationKey = $"http:source-resource:{operation}:{sourcePrefix}:{value}"; rateDimensions.Add(new($"{validationKey}:rate", rateLimit)); concurrencyDimensions.Add(new($"{validationKey}:concurrency", concurrencyLimit)); } string key = kind == "resource" ? $"http:resource-scoped:{operation}:{tenant ?? string.Empty}|{principal ?? string.Empty}:{value}" : $"http:{kind}:{operation}:{value}"; rateDimensions.Add(new($"{key}:rate", rateLimit)); concurrencyDimensions.Add(new($"{key}:concurrency", concurrencyLimit)); } } public bool TryAcceptUdpIngress(IPAddress? remoteAddress, string operation) { string prefix = GetNetworkPrefix(remoteAddress); RateDimension[] rates = [ new("udp:rate:global", _options.UdpGlobalDatagramsPerWindow), new($"udp:rate:ip:{prefix}", _options.UdpIpPrefixDatagramsPerWindow), new($"udp:rate:operation:{operation}", _options.UdpOperationDatagramsPerWindow), ]; return TryAcquire( rates, [], TrackerDomain.Udp, false, out AbuseLease? lease, out _) && DisposeAccepted(lease); } public bool TryAcceptUdpIdentity( string operation, string capability, string resource) => TryAcceptUdpIdentity( operation, null, capability, resource); public bool TryAcceptUdpIdentity( string operation, IPAddress? remoteAddress, string capability, string resource) { string sourcePrefix = GetNetworkPrefix(remoteAddress); string capabilityFingerprint = FingerprintSecret(capability); RateDimension[] rates = [ new($"udp:rate:capability:{operation}:{capabilityFingerprint}", _options.UdpCapabilityDatagramsPerWindow), new($"udp:rate:source-resource:{operation}:{sourcePrefix}:{resource}", _options.UdpResourceDatagramsPerWindow), new($"udp:rate:resource:{operation}:{capabilityFingerprint}:{resource}", _options.UdpResourceDatagramsPerWindow), ]; return TryAcquire( rates, [], TrackerDomain.Udp, false, out AbuseLease? lease, out _) && DisposeAccepted(lease); } public static string FingerprintSecret(string secret) { int byteCount = Encoding.UTF8.GetByteCount(secret); byte[]? rented = null; Span encoded = byteCount <= 1_024 ? stackalloc byte[byteCount] : (rented = ArrayPool.Shared.Rent(byteCount)).AsSpan(0, byteCount); Span digest = stackalloc byte[32]; try { _ = Encoding.UTF8.GetBytes(secret, encoded); _ = SHA256.HashData(encoded, digest); return Convert.ToHexString(digest[..12]); } finally { CryptographicOperations.ZeroMemory(encoded); CryptographicOperations.ZeroMemory(digest); if (rented is not null) { ArrayPool.Shared.Return(rented); } } } internal int TrackedKeyCount { get { int http; int udp; lock (_httpTracker.Gate) { http = _httpTracker.WindowCounts.Count + _httpTracker.ConcurrencyCounts.Count; } lock (_udpTracker.Gate) { udp = _udpTracker.WindowCounts.Count + _udpTracker.ConcurrencyCounts.Count; } return http + udp; } } private bool TryAcquire( ReadOnlySpan rates, ReadOnlySpan concurrency, TrackerDomain domain, bool canUseCriticalReserve, out AbuseLease? lease, out int retryAfterSeconds) { TrackerState tracker = domain == TrackerDomain.Udp ? _udpTracker : _httpTracker; bool accepted; lock (tracker.Gate) { accepted = TryAcquireLocked( tracker, rates, concurrency, domain, canUseCriticalReserve, out lease, out retryAfterSeconds); } if (!accepted) { _telemetry?.RecordLimiterDrop( domain == TrackerDomain.Udp ? "udp" : "http", "rate-or-concurrency"); } return accepted; } private bool TryAcquireLocked( TrackerState tracker, ReadOnlySpan rates, ReadOnlySpan concurrency, TrackerDomain domain, bool canUseCriticalReserve, out AbuseLease? lease, out int retryAfterSeconds) { DateTimeOffset now = _timeProvider.GetUtcNow(); TimeSpan window = TimeSpan.FromSeconds(_options.WindowSeconds); if (now - tracker.WindowStartedAt >= window || now < tracker.WindowStartedAt) { tracker.WindowCounts.Clear(); tracker.WindowStartedAt = now; } retryAfterSeconds = Math.Max( 1, (int)Math.Ceiling((window - (now - tracker.WindowStartedAt)).TotalSeconds)); int stagedNewKeys = 0; int partitionLimit = domain == TrackerDomain.Udp ? _options.UdpTrackedKeyLimit : _options.MaxTrackedKeys - _options.UdpTrackedKeyLimit; int maxTrackedKeys = domain == TrackerDomain.Udp || canUseCriticalReserve ? partitionLimit : partitionLimit - _options.CriticalTrackedKeyReserve; if (!CanAcquireAll( tracker, tracker.WindowCounts, rates, maxTrackedKeys, ref stagedNewKeys) || !CanAcquireAll( tracker, tracker.ConcurrencyCounts, concurrency, maxTrackedKeys, ref stagedNewKeys)) { lease = null; return false; } foreach (RateDimension dimension in rates) { tracker.WindowCounts[dimension.Key] = tracker.WindowCounts.GetValueOrDefault(dimension.Key) + 1; } if (concurrency.IsEmpty) { lease = null; return true; } string[] acquiredConcurrency = new string[concurrency.Length]; for (int index = 0; index < concurrency.Length; index++) { RateDimension dimension = concurrency[index]; tracker.ConcurrencyCounts[dimension.Key] = tracker.ConcurrencyCounts.GetValueOrDefault(dimension.Key) + 1; acquiredConcurrency[index] = dimension.Key; } lease = new AbuseLease(this, tracker, acquiredConcurrency); return true; } private static bool CanAcquireAll( TrackerState tracker, Dictionary counts, ReadOnlySpan dimensions, int maxTrackedKeys, ref int stagedNewKeys) { foreach (RateDimension dimension in dimensions) { if (counts.TryGetValue(dimension.Key, out int current)) { if (current >= dimension.Limit) { return false; } continue; } stagedNewKeys++; if (tracker.WindowCounts.Count + tracker.ConcurrencyCounts.Count + stagedNewKeys > maxTrackedKeys) { return false; } } return true; } private static void Release(TrackerState tracker, string[] keys) { lock (tracker.Gate) { foreach (string key in keys) { if (!tracker.ConcurrencyCounts.TryGetValue(key, out int current)) { continue; } if (current <= 1) { tracker.ConcurrencyCounts.Remove(key); } else { tracker.ConcurrencyCounts[key] = current - 1; } } } } private static bool DisposeAccepted(AbuseLease? lease) { lease?.Dispose(); return true; } private static bool IsLeaseCriticalOperation(string operation) => operation is "RenewSessionLease" or "UpdateSession" or "DeleteSession"; private static string GetNetworkPrefix(IPAddress? address) { if (address is null) { return "unknown"; } IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; byte[] bytes = normalized.GetAddressBytes(); if (bytes.Length == 4) { bytes[3] = 0; return $"4:{Convert.ToHexString(bytes)}:24"; } if (bytes.Length == 16) { Array.Clear(bytes, 7, 9); return $"6:{Convert.ToHexString(bytes)}:56"; } return "unknown"; } private static IPAddress NormalizeAddress(IPAddress address) => address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; private readonly record struct RateDimension(string Key, int Limit); private enum TrackerDomain { Http, Udp, } internal sealed class TrackerState(DateTimeOffset windowStartedAt) { public object Gate { get; } = new(); public Dictionary WindowCounts { get; } = new(StringComparer.Ordinal); public Dictionary ConcurrencyCounts { get; } = new(StringComparer.Ordinal); public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt; } internal sealed class AbuseLease : IDisposable { private AbuseProtectionService? _owner; private readonly TrackerState _tracker; private readonly string[] _keys; internal AbuseLease( AbuseProtectionService owner, TrackerState tracker, string[] keys) { _owner = owner; _tracker = tracker; _keys = keys; } public void Dispose() { if (Interlocked.Exchange(ref _owner, null) is not null) { Release(_tracker, _keys); } } } }