using System.Net; using System.Text.Json; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Http; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace FinalFactory.Rendezvous.Tests.Server.Abuse; public sealed class AbuseProtectionTests { [Fact] public void Ipv4AndIpv6PrefixesShareBudgetsAndRecoverAfterTheWindow() { ManualTimeProvider time = new(new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero)); AbuseProtectionOptions options = PermissiveOptions(); options.HttpIpPrefixRequestsPerWindow = 2; AbuseProtectionService protection = new(Options.Create(options), time); AssertAccepted(protection, IPAddress.Parse("198.51.100.10"), "BrowseSessions"); AssertAccepted(protection, IPAddress.Parse("198.51.100.200"), "BrowseSessions"); AssertRejected(protection, IPAddress.Parse("198.51.100.99"), "BrowseSessions"); time.Advance(TimeSpan.FromSeconds(1)); AssertAccepted(protection, IPAddress.Parse("198.51.100.99"), "BrowseSessions"); options = PermissiveOptions(); options.HttpIpPrefixRequestsPerWindow = 1; protection = new(Options.Create(options), time); AssertAccepted(protection, IPAddress.Parse("2606:4700:1234:5600::1"), "GetSession"); AssertRejected(protection, IPAddress.Parse("2606:4700:1234:56ff::2"), "GetSession"); AssertAccepted(protection, IPAddress.Parse("2606:4700:1234:5700::2"), "GetSession"); } [Fact] public void PrincipalConcurrencyIsReleasedAndRejectedCallsDoNotConsumeRate() { AbuseProtectionOptions options = PermissiveOptions(); options.HttpPrincipalConcurrency = 1; options.HttpPrincipalRequestsPerWindow = 2; AbuseProtectionService protection = new(Options.Create(options)); Assert.True(protection.TryAcquireHttpIdentity( "RegisterSession", "game/prod", "publisher-1", null, out var first, out _)); Assert.False(protection.TryAcquireHttpIdentity( "RegisterSession", "game/prod", "publisher-1", null, out _, out _)); first!.Dispose(); Assert.True(protection.TryAcquireHttpIdentity( "RegisterSession", "game/prod", "publisher-1", null, out var second, out _)); second!.Dispose(); Assert.False(protection.TryAcquireHttpIdentity( "RegisterSession", "game/prod", "publisher-1", null, out _, out _)); } [Fact] public void TrackerCapacityFailsClosedWithoutGrowingAndAWindowResetRecovers() { ManualTimeProvider time = new(new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero)); AbuseProtectionOptions options = PermissiveOptions(); options.MaxTrackedKeys = 10; options.UdpTrackedKeyLimit = 0; AbuseProtectionService protection = new(Options.Create(options), time); AssertAccepted(protection, IPAddress.Parse("198.51.100.1"), "GetSession"); AssertRejected(protection, IPAddress.Parse("203.0.113.1"), "GetSession"); Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys); time.Advance(TimeSpan.FromSeconds(1)); AssertAccepted(protection, IPAddress.Parse("203.0.113.1"), "GetSession"); Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys); } [Fact] public void OptionalTrafficCannotConsumeTheLeaseOperationReserve() { AbuseProtectionOptions options = PermissiveOptions(); options.HttpGlobalRequestsPerWindow = 3; options.HttpOptionalRequestsPerWindow = 2; options.HttpIpPrefixRequestsPerWindow = 3; options.HttpOptionalIpPrefixRequestsPerWindow = 2; AbuseProtectionService protection = new(Options.Create(options)); IPAddress source = IPAddress.Parse("198.51.100.10"); Assert.True(protection.IsOperatorSourceAllowed(source)); Assert.True(protection.IsOperatorSourceAllowed(IPAddress.Parse("::ffff:198.51.100.10"))); Assert.False(protection.IsOperatorSourceAllowed(IPAddress.Parse("198.51.100.11"))); AssertAccepted(protection, source, "BrowseSessions"); AssertAccepted(protection, source, "BrowseSessions"); AssertRejected(protection, source, "BrowseSessions"); AssertAccepted(protection, source, "RenewSessionLease"); AssertRejected(protection, source, "RenewSessionLease"); } [Fact] public void PublicSaturationCannotConsumeTheOperatorPartition() { AbuseProtectionOptions options = PermissiveOptions(); options.HttpGlobalRequestsPerWindow = 1; options.HttpOptionalRequestsPerWindow = 1; options.OperatorGlobalRequestsPerWindow = 1; AbuseProtectionService protection = new(Options.Create(options)); IPAddress source = IPAddress.Parse("198.51.100.10"); AssertAccepted(protection, source, "BrowseSessions"); AssertRejected(protection, source, "BrowseSessions"); Assert.True(protection.TryAcquireOperatorIngress(source, out var lease, out _)); lease!.Dispose(); Assert.False(protection.TryAcquireOperatorIngress(source, out _, out _)); } [Fact] public async Task DeniedOperatorSourcesConsumeTheBoundedPublicPartition() { AbuseProtectionOptions options = PermissiveOptions(); options.OperatorAllowedAddresses = ["192.0.2.10"]; options.HttpGlobalRequestsPerWindow = 1; options.HttpOptionalRequestsPerWindow = 1; options.HttpIpPrefixRequestsPerWindow = 1; options.HttpOptionalIpPrefixRequestsPerWindow = 1; AbuseProtectionService protection = new(Options.Create(options)); bool dispatched = false; HttpAbuseProtectionMiddleware middleware = new( _ => { dispatched = true; return Task.CompletedTask; }, protection); DefaultHttpContext first = Context("198.51.100.10"); first.SetEndpoint(new Endpoint( _ => Task.CompletedTask, new EndpointMetadataCollection(new EndpointNameMetadata("GetOperatorStatus")), "operator-status")); await middleware.InvokeAsync(first); Assert.Equal(StatusCodes.Status404NotFound, first.Response.StatusCode); DefaultHttpContext repeated = Context("198.51.100.10"); repeated.SetEndpoint(first.GetEndpoint()); await middleware.InvokeAsync(repeated); Assert.Equal(StatusCodes.Status429TooManyRequests, repeated.Response.StatusCode); Assert.False(dispatched); } [Fact] public void ResourceBudgetsRemainIsolatedAcrossTenantAndPrincipalScopes() { AbuseProtectionOptions options = PermissiveOptions(); options.HttpResourceRequestsPerWindow = 1; AbuseProtectionService protection = new(Options.Create(options)); Assert.True(protection.TryAcquireHttpIdentity( "UpdateSession", IPAddress.Parse("198.51.100.10"), "game-a/prod", "publisher", "listing", out var first, out _)); first!.Dispose(); Assert.False(protection.TryAcquireHttpIdentity( "UpdateSession", IPAddress.Parse("198.51.100.10"), "game-a/prod", "publisher", "listing", out _, out _)); Assert.True(protection.TryAcquireHttpIdentity( "UpdateSession", IPAddress.Parse("203.0.113.10"), "game-b/prod", "publisher", "listing", out var second, out _)); second!.Dispose(); Assert.True(protection.TryAcquireHttpIdentity( "UpdateSession", IPAddress.Parse("192.0.2.10"), "game-a/prod", "other-publisher", "listing", out var third, out _)); third!.Dispose(); } [Fact] public void RotatingCredentialsCannotBypassIndependentResourceBudgets() { AbuseProtectionOptions options = PermissiveOptions(); options.HttpResourceRequestsPerWindow = 2; options.UdpResourceDatagramsPerWindow = 2; AbuseProtectionService protection = new(Options.Create(options)); for (int index = 1; index <= 2; index++) { Assert.True(protection.TryAcquireHttpIdentity( "CancelJoinAttempt", null, $"capability-{index}", "attempt", out var lease, out _)); lease!.Dispose(); Assert.True(protection.TryAcceptUdpIdentity( "Client", $"capability-{index}", "mediation-handle")); } Assert.False(protection.TryAcquireHttpIdentity( "CancelJoinAttempt", null, "capability-3", "attempt", out _, out _)); Assert.False(protection.TryAcceptUdpIdentity( "Client", "capability-3", "mediation-handle")); } [Fact] public void UdpWireOperationsHaveIndependentBoundedIngressBudgets() { AbuseProtectionOptions options = PermissiveOptions(); options.UdpOperationDatagramsPerWindow = 1; AbuseProtectionService protection = new(Options.Create(options)); IPAddress source = IPAddress.Parse("198.51.100.10"); Assert.True(protection.TryAcceptUdpIngress(source, "frozen")); Assert.False(protection.TryAcceptUdpIngress(source, "frozen")); Assert.True(protection.TryAcceptUdpIngress(source, "litenet-or-invalid")); Assert.False(protection.TryAcceptUdpIngress(source, "litenet-or-invalid")); } [Fact] public void UdpTrackerExhaustionCannotConsumeTheCriticalHttpKeyReserve() { AbuseProtectionOptions options = PermissiveOptions(); options.MaxTrackedKeys = 28; options.CriticalTrackedKeyReserve = 16; options.UdpTrackedKeyLimit = 12; AbuseProtectionService protection = new(Options.Create(options)); for (int index = 1; index <= 3; index++) { IPAddress address = IPAddress.Parse($"198.51.{index}.1"); _ = protection.TryAcceptUdpIngress(address, "raw"); _ = protection.TryAcceptUdpIdentity("Client", $"capability-{index}", $"resource-{index}"); } Assert.InRange(protection.TrackedKeyCount, 1, 12); Assert.True(protection.TryAcquireHttpIngress( IPAddress.Parse("203.0.113.10"), "RenewSessionLease", out var ingress, out _)); Assert.True(protection.TryAcquireHttpIdentity( "RenewSessionLease", "game/prod", "publisher", "listing", out var identity, out _)); identity!.Dispose(); ingress!.Dispose(); Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys); } [Fact] public async Task HttpOverloadIsTypedAndOversizedBodiesAreRejectedBeforeDispatch() { AbuseProtectionOptions options = PermissiveOptions(); options.HttpIpPrefixRequestsPerWindow = 1; AbuseProtectionService protection = new(Options.Create(options)); int dispatched = 0; HttpAbuseProtectionMiddleware middleware = new( _ => { dispatched++; return Task.CompletedTask; }, protection); DefaultHttpContext accepted = Context("198.51.100.10"); await middleware.InvokeAsync(accepted); Assert.Equal(1, dispatched); DefaultHttpContext limited = Context("198.51.100.11"); await middleware.InvokeAsync(limited); Assert.Equal(StatusCodes.Status429TooManyRequests, limited.Response.StatusCode); Assert.Equal("1", limited.Response.Headers.RetryAfter); limited.Response.Body.Position = 0; ApiError? error = await JsonSerializer.DeserializeAsync( limited.Response.Body, ContractJson.Options); Assert.Equal(RendezvousErrorCode.RateLimited, error?.Code); Assert.Equal(1, error?.RetryAfterSeconds); Assert.Equal(1, dispatched); DefaultHttpContext oversized = Context("203.0.113.1"); oversized.Request.ContentLength = ContractLimits.HttpRequestMaxBytes + 1; await middleware.InvokeAsync(oversized); Assert.Equal(StatusCodes.Status413PayloadTooLarge, oversized.Response.StatusCode); Assert.Equal(1, dispatched); DefaultHttpContext repeatedOversized = Context("203.0.113.2"); repeatedOversized.Request.ContentLength = ContractLimits.HttpRequestMaxBytes + 1; await middleware.InvokeAsync(repeatedOversized); Assert.Equal(StatusCodes.Status429TooManyRequests, repeatedOversized.Response.StatusCode); Assert.Equal(1, dispatched); } [Fact] public void DeterministicHostileUdpCorpusNeverThrowsOrAcceptsOversizedDatagrams() { const int seed = 0x15_2026; Random random = new(seed); for (int iteration = 0; iteration < 10_000; iteration++) { int length = random.Next(0, ContractLimits.UdpDatagramMaxBytes + 257); byte[] payload = new byte[length]; random.NextBytes(payload); bool decoded = RendezvousUdpCodec.TryDecode( payload, out PresenceDatagram? datagram, out UdpDecodeError error); if (length > ContractLimits.UdpDatagramMaxBytes) { Assert.False(decoded); Assert.Null(datagram); Assert.Equal(UdpDecodeError.DatagramTooLarge, error); } } } [Fact] public void ConcurrentAbusiveBurstStaysBoundedAndCannotBlockCriticalHttp() { AbuseProtectionOptions options = PermissiveOptions(); options.MaxTrackedKeys = 2_000; options.UdpTrackedKeyLimit = 1_000; options.CriticalTrackedKeyReserve = 100; options.UdpGlobalDatagramsPerWindow = 100_000; options.UdpIpPrefixDatagramsPerWindow = 100_000; options.UdpOperationDatagramsPerWindow = 100_000; AbuseProtectionService protection = new(Options.Create(options)); IPAddress source = IPAddress.Parse("198.51.100.10"); Parallel.For(0, 20_000, index => { _ = protection.TryAcceptUdpIngress(source, "raw"); _ = protection.TryAcceptUdpIdentity( "Client", $"capability-{index}", $"resource-{index}"); }); Assert.InRange(protection.TrackedKeyCount, 1, options.UdpTrackedKeyLimit); Assert.True(protection.TryAcquireHttpIngress( IPAddress.Parse("203.0.113.10"), "RenewSessionLease", out var lease, out _)); lease!.Dispose(); Assert.InRange(protection.TrackedKeyCount, 1, options.MaxTrackedKeys); } [Fact] public void SteadyStateUdpAdmissionHasABoundedAllocationBudget() { AbuseProtectionOptions options = PermissiveOptions(); options.UdpGlobalDatagramsPerWindow = 100_000; options.UdpIpPrefixDatagramsPerWindow = 100_000; options.UdpOperationDatagramsPerWindow = 100_000; options.UdpCapabilityDatagramsPerWindow = 100_000; options.UdpResourceDatagramsPerWindow = 100_000; AbuseProtectionService protection = new(Options.Create(options)); IPAddress source = IPAddress.Parse("198.51.100.10"); _ = protection.TryAcceptUdpIngress(source, "frozen"); _ = protection.TryAcceptUdpIdentity("Host", source, "capability", "resource"); long before = GC.GetAllocatedBytesForCurrentThread(); for (int iteration = 0; iteration < 10_000; iteration++) { Assert.True(protection.TryAcceptUdpIngress(source, "frozen")); Assert.True(protection.TryAcceptUdpIdentity( "Host", source, "capability", "resource")); } long allocated = GC.GetAllocatedBytesForCurrentThread() - before; Assert.InRange(allocated, 0, 40_000_000); } [Fact] public void DeterministicHttpAndCredentialParserCorpusHasOnlyTypedRejections() { const int seed = 0x15_4A50; Random random = new(seed); for (int iteration = 0; iteration < 5_000; iteration++) { byte[] bytes = new byte[random.Next(0, 1_025)]; random.NextBytes(bytes); try { _ = JsonSerializer.Deserialize(bytes, ContractJson.Options); } catch (JsonException) { } string token = Convert.ToBase64String(bytes); Assert.False(NatPunchRequestTokenCodec.TryDecode(token, out _)); Assert.False(NatIntroductionTokenCodec.TryDecode(token, out _)); } } [Fact] public void SecretFingerprintsAreStableBoundedAndDoNotContainHostileInput() { const string hostile = "\r\nAuthorization: secret"; string fingerprint = AbuseProtectionService.FingerprintSecret(hostile); Assert.Equal(fingerprint, AbuseProtectionService.FingerprintSecret(hostile)); Assert.Equal(24, fingerprint.Length); Assert.DoesNotContain("script", fingerprint, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("secret", fingerprint, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ExceptionResponsesPreservePayloadStatusWithoutEchoingHostileDetails() { const string canary = "credential-canary