Files
Rendezvous/tests/FinalFactory.Rendezvous.Tests/Server/Abuse/AbuseProtectionTests.cs
KyuubiYoru 88ef946af5
quality-gate / quality (push) Failing after 1m5s
feat(server): harden hostile input and overload behavior (#15)
2026-07-16 12:37:38 +02:00

473 lines
19 KiB
C#

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.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");
AssertAccepted(protection, source, "BrowseSessions");
AssertAccepted(protection, source, "BrowseSessions");
AssertRejected(protection, source, "BrowseSessions");
AssertAccepted(protection, source, "RenewSessionLease");
AssertRejected(protection, source, "RenewSessionLease");
}
[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<ApiError>(
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<RegisterSessionRequest>(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 = "<script>steal('token')</script>\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 <script> endpoint=203.0.113.8:9000";
DefaultHttpContext context = Context("198.51.100.10");
RendezvousExceptionHandler handler = new();
Assert.True(await handler.TryHandleAsync(
context,
new BadHttpRequestException(canary, StatusCodes.Status413PayloadTooLarge),
CancellationToken.None));
Assert.Equal(StatusCodes.Status413PayloadTooLarge, context.Response.StatusCode);
context.Response.Body.Position = 0;
using StreamReader reader = new(context.Response.Body);
string body = await reader.ReadToEndAsync();
Assert.DoesNotContain(canary, body, StringComparison.Ordinal);
Assert.DoesNotContain("203.0.113.8", body, StringComparison.Ordinal);
Assert.DoesNotContain("script", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ForwardedSourcesAreDefaultDenyExactProxyOnlyAndSingleHop()
{
AbuseProtectionOptions disabled = new();
Assert.False(TrustedProxyForwarding.IsEnabled(disabled));
AbuseProtectionOptions enabled = new()
{
TrustedProxyAddresses = ["192.0.2.10"],
};
Assert.True(TrustedProxyForwarding.IsEnabled(enabled));
ForwardedHeadersOptions forwarded = new();
TrustedProxyForwarding.Configure(forwarded, enabled);
ForwardedHeadersMiddleware middleware = new(
_ => Task.CompletedTask,
NullLoggerFactory.Instance,
Options.Create(forwarded));
DefaultHttpContext trusted = Context("192.0.2.10");
trusted.Request.Headers["X-Forwarded-For"] = "198.51.100.7";
await middleware.Invoke(trusted);
Assert.Equal(IPAddress.Parse("198.51.100.7"), trusted.Connection.RemoteIpAddress);
DefaultHttpContext untrusted = Context("192.0.2.11");
untrusted.Request.Headers["X-Forwarded-For"] = "198.51.100.8";
await middleware.Invoke(untrusted);
Assert.Equal(IPAddress.Parse("192.0.2.11"), untrusted.Connection.RemoteIpAddress);
DefaultHttpContext multiHop = Context("192.0.2.10");
multiHop.Request.Headers["X-Forwarded-For"] = "198.51.100.9, 203.0.113.9";
await middleware.Invoke(multiHop);
Assert.Equal(IPAddress.Parse("203.0.113.9"), multiHop.Connection.RemoteIpAddress);
}
private static DefaultHttpContext Context(string address)
{
DefaultHttpContext context = new();
context.Connection.RemoteIpAddress = IPAddress.Parse(address);
context.Response.Body = new MemoryStream();
return context;
}
private static void AssertAccepted(
AbuseProtectionService protection,
IPAddress address,
string operation)
{
Assert.True(protection.TryAcquireHttpIngress(
address, operation, out var lease, out _));
lease!.Dispose();
}
private static void AssertRejected(
AbuseProtectionService protection,
IPAddress address,
string operation) => Assert.False(protection.TryAcquireHttpIngress(
address, operation, out _, out _));
private static AbuseProtectionOptions PermissiveOptions() => new()
{
WindowSeconds = 1,
MaxTrackedKeys = 10_000,
CriticalTrackedKeyReserve = 0,
UdpTrackedKeyLimit = 5_000,
HealthGlobalRequestsPerWindow = 10_000,
HealthGlobalConcurrency = 10_000,
HealthIpPrefixRequestsPerWindow = 10_000,
HealthIpPrefixConcurrency = 1_000,
HttpGlobalRequestsPerWindow = 10_000,
HttpOptionalRequestsPerWindow = 9_000,
HttpIpPrefixRequestsPerWindow = 10_000,
HttpOptionalIpPrefixRequestsPerWindow = 9_000,
HttpOperationRequestsPerWindow = 10_000,
HttpTenantRequestsPerWindow = 10_000,
HttpPrincipalRequestsPerWindow = 10_000,
HttpResourceRequestsPerWindow = 10_000,
HttpGlobalConcurrency = 10_000,
HttpOptionalConcurrency = 9_000,
HttpIpPrefixConcurrency = 10_000,
HttpOptionalIpPrefixConcurrency = 9_000,
HttpOperationConcurrency = 10_000,
HttpTenantConcurrency = 10_000,
HttpPrincipalConcurrency = 10_000,
HttpResourceConcurrency = 10_000,
UdpGlobalDatagramsPerWindow = 10_000,
UdpIpPrefixDatagramsPerWindow = 10_000,
UdpOperationDatagramsPerWindow = 10_000,
UdpCapabilityDatagramsPerWindow = 10_000,
UdpResourceDatagramsPerWindow = 10_000,
};
private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _utcNow = utcNow;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration) => _utcNow += duration;
}
}