diff --git a/README.md b/README.md index ec8eff5..c6ec371 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ The frozen v1 wire surface is documented in the [HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md). Tenant policy, publisher/operator principals, and production key custody are defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md). +Layered HTTP/UDP budgets, overload behavior, and safe operational tuning are +defined in [hostile-input and overload protection](docs/security/abuse-protection.md). The scriptable host/browser/join diagnostic and its stable automation contract are documented in the [TestClient integration guide](docs/integration/test-client.md). The always-on three-party scenarios, optional Linux namespace topology, and diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json index 2b41b93..99f4020 100644 --- a/docs/api/rendezvous-v1.json +++ b/docs/api/rendezvous-v1.json @@ -21,6 +21,25 @@ } } } + }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } } } } @@ -42,6 +61,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable" } @@ -85,6 +123,16 @@ } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "401": { "description": "Unauthorized", "content": { @@ -127,6 +175,15 @@ }, "429": { "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, "content": { "application/json": { "schema": { @@ -243,6 +300,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -303,6 +379,16 @@ } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "401": { "description": "Unauthorized", "content": { @@ -353,6 +439,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -411,6 +516,16 @@ } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "401": { "description": "Unauthorized", "content": { @@ -441,6 +556,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -497,6 +631,16 @@ } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "401": { "description": "Unauthorized", "content": { @@ -517,6 +661,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -614,6 +777,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -706,6 +888,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -756,6 +957,16 @@ } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "404": { "description": "Not Found", "content": { @@ -788,6 +999,15 @@ }, "429": { "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, "content": { "application/json": { "schema": { @@ -857,6 +1077,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { @@ -930,6 +1169,16 @@ } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "404": { "description": "Not Found", "content": { @@ -950,6 +1199,25 @@ } } }, + "429": { + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "description": "Whole seconds before the caller should retry (1-60).", + "schema": { + "type": "integer", + "format": "int32" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "503": { "description": "Service Unavailable", "content": { diff --git a/docs/security/abuse-protection.md b/docs/security/abuse-protection.md new file mode 100644 index 0000000..066335c --- /dev/null +++ b/docs/security/abuse-protection.md @@ -0,0 +1,98 @@ +# Hostile-input and overload protection + +Tracking: #15 + +Rendezvous treats every public HTTP request and UDP datagram as hostile. The +server applies bounded fixed-window request budgets and concurrency ceilings in +two stages so malformed input is discarded before expensive work while valid +traffic is also isolated by its authenticated scope. + +## Enforcement order + +1. Kestrel and the HTTP abuse middleware cap request bodies at 16 KiB. A known + oversized body receives a typed `413` response before endpoint dispatch. +2. Every HTTP request consumes global, source-prefix, and operation budgets and + acquires the corresponding concurrency leases. IPv4 sources share a `/24` + budget and IPv6 sources share a `/56` budget; raw addresses are not retained. + Non-lease operations also consume a smaller optional-work budget, leaving a + configured global and source-prefix reserve for renew, update, and delete + operations during shedding. + Health probes use their own source-prefix budget so public API overload cannot + make a healthy instance fail its orchestrator probes, while health traffic is + still bounded. +3. Once an endpoint has safely derived identities, it also acquires applicable + tenant, principal or capability, and listing/attempt budgets. Secret + capabilities are represented only by bounded SHA-256 fingerprints. +4. Every UDP envelope consumes global, source-prefix, and wire-operation + budgets before decoding. A structurally and cryptographically valid request + then consumes capability, role, and mediation-handle budgets before state + mutation or introduction. +5. HTTP overload returns the stable `RateLimited` error, status `429`, and a + bounded `Retry-After` value in both the header and response contract. UDP + overload and every invalid UDP input are silently dropped. + +The same HTTP identity budget is computed whether or not a listing or attempt +exists. Rejection therefore does not disclose resource existence. Publisher +authentication also completes before any tenant/resource operation, while the +pre-authentication source budget prevents invalid credentials from bypassing +load shedding. + +## Bounded state and recovery + +`Rendezvous:AbuseProtection:MaxTrackedKeys` is a hard combined ceiling for rate +and active-concurrency keys. General HTTP and UDP traffic cannot consume the +configured `CriticalTrackedKeyReserve`; lease operations and health probes may +use that reserve but never exceed the hard ceiling. A request that would exceed +its applicable ceiling fails closed without adding state. Fixed-window rate keys +are cleared at the next window boundary; concurrency keys are removed as their +request leases finish. HTTP and UDP trackers have separate locks and cardinality +partitions, so a UDP flood cannot block HTTP admission on a shared lock or +consume HTTP key capacity. This gives +deterministic burst recovery and prevents an attacker from growing a permanent +high-cardinality address, credential, or resource table. + +The complete default profile is checked into +`src/FinalFactory.Rendezvous.Server/appsettings.json`. Operators may lower or +tune limits for a measured deployment profile, but must preserve all dimensions +and leave the tracker ceiling above the maximum simultaneous key set. A rolling +deployment should use the same profile on every instance. These per-process +limits are a final service boundary; an edge proxy may add stricter distributed +limits but is not a substitute for them. + +When an HTTP reverse proxy is used, every immediate proxy address must be +allowlisted in `Rendezvous:AbuseProtection:TrustedProxyAddresses` (or indexed +environment variables such as +`Rendezvous__AbuseProtection__TrustedProxyAddresses__0`). Only one forwarded +hop is accepted. With an empty allowlist, forwarded headers are ignored and the +direct TCP peer is the source. Never add a broad network range or accept +untrusted `X-Forwarded-For` input: that would let a caller choose its own rate +partition. + +## Reflection, disclosure, and logging rules + +- UDP sends nothing for malformed, oversized, unauthenticated, stale, + replayed, wrong-role, or rate-limited input. +- Introductions are emitted only after both role-scoped capabilities bind to + their observed gameplay-socket sources. HTTP never supplies a public + introduction target. +- Private candidates must be same-family private unicast addresses and are used + only for peers observed behind the same public address. +- Abuse keys, exceptions, and responses never include bearer credentials, + capabilities, tickets, raw endpoints, metadata values, or hostile markup. +- Endpoint and capability values are not used as metric labels or log fields. + +## Verification + +The deterministic test corpora use the recorded seeds `0x152026`, `0x154A50`, +and `0x1557A7E`. They exercise 10,000 arbitrary UDP envelopes through the +production decoder, 5,000 arbitrary HTTP/credential parser inputs, and 1,000 +mutated state transitions, including the oversized and configured-capacity +boundaries. +Focused tests cover IPv4 and IPv6 prefix +partitioning, tenant/principal/resource concurrency, tracker exhaustion, +window recovery, wire-operation isolation, a steady-state allocation ceiling, +typed `429`/`413` responses, secret fingerprint redaction, and silent +authenticated UDP shedding. The existing state, contract, HTTP, client, +and mediator suites continue to cover cross-tenant access, replay, role swaps, +credential rotation, bounded metadata, endpoint validation, and one-shot +amplification behavior. diff --git a/docs/security/control-matrix.md b/docs/security/control-matrix.md index b779ba0..a2a12e8 100644 --- a/docs/security/control-matrix.md +++ b/docs/security/control-matrix.md @@ -11,7 +11,7 @@ backlog where the control is implemented and verified. | Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. | | Short-lived, single-purpose tokens resistant to replay | Issuer fixes audience, tenant, attempt, role, issued/expiry times, nonce, and key ID; store atomically consumes nonce/ticket. (#4, #6, #10) | Golden vectors; expired, future, mutated, wrong-role, wrong-tenant, and concurrent replay tests. | | Strict payload, metadata, and token size limits | ADR 0003 ceilings are checked before allocation/deserialization and again at domain construction. (#4, #15) | Boundary/property tests, malformed corpus, and allocation-aware fuzzing. | -| Registration, query, and introduction rate limits | Layered per-address, principal, tenant, and global token buckets with bounded queues and stable retry guidance. (#15) | Limit partition/isolation tests and overload/soak profiles. | +| Registration, query, and introduction rate limits | Layered fixed-window budgets and concurrency leases cover global, operation, IPv4 `/24` or IPv6 `/56`, tenant, principal/capability, and listing/attempt dimensions with a bounded key table and stable retry guidance. (#15) | Deterministic partition, concurrency, tracker-exhaustion, recovery, typed-overload, and silent-UDP-shedding tests. | | Lease expiry removes abandoned servers | Visibility and join eligibility atomically require a fresh lease and fresh authenticated presence. (#6, #7) | Fake-clock expiry, renew/expire race, restart, and stale-host join tests. | | Validate game, environment, room, and protocol boundaries | Every identifier is a validated type; store keys and authorization decisions include server-derived tenant scope; protocol is exact-match in v1. (#4-#10) | Contract, tenant-isolation, incompatible-version, and confused-deputy tests. | | Structured audit events without secrets or reusable credentials | Allowlisted audit schema excludes metadata values, raw endpoints, tokens, and key material; event volume is bounded. (#16) | Captured-log/audit assertions and credential canary scans. | diff --git a/src/FinalFactory.Rendezvous.Server/Abuse/AbuseProtectionOptions.cs b/src/FinalFactory.Rendezvous.Server/Abuse/AbuseProtectionOptions.cs new file mode 100644 index 0000000..b883075 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Abuse/AbuseProtectionOptions.cs @@ -0,0 +1,97 @@ +using System.ComponentModel.DataAnnotations; + +namespace FinalFactory.Rendezvous.Server.Abuse; + +internal sealed class AbuseProtectionOptions +{ + public const string SectionName = "Rendezvous:AbuseProtection"; + + [Range(1, 60)] + public int WindowSeconds { get; set; } = 1; + + [Range(1_000, 1_000_000)] + public int MaxTrackedKeys { get; set; } = 100_000; + + [Range(0, 100_000)] + public int CriticalTrackedKeyReserve { get; set; } = 2_048; + + [Range(1_000, 999_999)] + public int UdpTrackedKeyLimit { get; set; } = 70_000; + + public string[] TrustedProxyAddresses { get; set; } = []; + + [Range(1, 100_000)] + public int HealthGlobalRequestsPerWindow { get; set; } = 1_000; + + [Range(1, 10_000)] + public int HealthGlobalConcurrency { get; set; } = 32; + + [Range(1, 100_000)] + public int HealthIpPrefixRequestsPerWindow { get; set; } = 120; + + [Range(1, 1_000)] + public int HealthIpPrefixConcurrency { get; set; } = 8; + + [Range(1, 1_000_000)] + public int HttpGlobalRequestsPerWindow { get; set; } = 20_000; + + [Range(1, 1_000_000)] + public int HttpOptionalRequestsPerWindow { get; set; } = 18_000; + + [Range(1, 100_000)] + public int HttpIpPrefixRequestsPerWindow { get; set; } = 500; + + [Range(1, 100_000)] + public int HttpOptionalIpPrefixRequestsPerWindow { get; set; } = 450; + + [Range(1, 1_000_000)] + public int HttpOperationRequestsPerWindow { get; set; } = 5_000; + + [Range(1, 1_000_000)] + public int HttpTenantRequestsPerWindow { get; set; } = 2_000; + + [Range(1, 100_000)] + public int HttpPrincipalRequestsPerWindow { get; set; } = 500; + + [Range(1, 100_000)] + public int HttpResourceRequestsPerWindow { get; set; } = 200; + + [Range(1, 100_000)] + public int HttpGlobalConcurrency { get; set; } = 1_024; + + [Range(1, 100_000)] + public int HttpOptionalConcurrency { get; set; } = 768; + + [Range(1, 10_000)] + public int HttpIpPrefixConcurrency { get; set; } = 64; + + [Range(1, 10_000)] + public int HttpOptionalIpPrefixConcurrency { get; set; } = 48; + + [Range(1, 100_000)] + public int HttpOperationConcurrency { get; set; } = 256; + + [Range(1, 100_000)] + public int HttpTenantConcurrency { get; set; } = 256; + + [Range(1, 10_000)] + public int HttpPrincipalConcurrency { get; set; } = 32; + + [Range(1, 10_000)] + public int HttpResourceConcurrency { get; set; } = 16; + + [Range(1, 10_000_000)] + public int UdpGlobalDatagramsPerWindow { get; set; } = 100_000; + + [Range(1, 1_000_000)] + public int UdpIpPrefixDatagramsPerWindow { get; set; } = 2_000; + + [Range(1, 10_000_000)] + public int UdpOperationDatagramsPerWindow { get; set; } = 50_000; + + [Range(1, 100_000)] + public int UdpCapabilityDatagramsPerWindow { get; set; } = 120; + + [Range(1, 100_000)] + public int UdpResourceDatagramsPerWindow { get; set; } = 240; +} diff --git a/src/FinalFactory.Rendezvous.Server/Abuse/AbuseProtectionService.cs b/src/FinalFactory.Rendezvous.Server/Abuse/AbuseProtectionService.cs new file mode 100644 index 0000000..2f8d788 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Abuse/AbuseProtectionService.cs @@ -0,0 +1,458 @@ +using System.Buffers; +using System.Net; +using System.Security.Cryptography; +using System.Text; +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; + + public AbuseProtectionService( + IOptions options, + TimeProvider? timeProvider = null) + { + _options = options.Value; + _timeProvider = timeProvider ?? TimeProvider.System; + 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 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; + lock (tracker.Gate) + { + 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 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); + } + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Abuse/HttpAbuseProtectionMiddleware.cs b/src/FinalFactory.Rendezvous.Server/Abuse/HttpAbuseProtectionMiddleware.cs new file mode 100644 index 0000000..e3b2792 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Abuse/HttpAbuseProtectionMiddleware.cs @@ -0,0 +1,80 @@ +using FinalFactory.Rendezvous.Contracts; +using Microsoft.AspNetCore.Http.Features; + +namespace FinalFactory.Rendezvous.Server.Abuse; + +internal sealed class HttpAbuseProtectionMiddleware( + RequestDelegate next, + AbuseProtectionService protection) +{ + public async Task InvokeAsync(HttpContext context) + { + IHttpMaxRequestBodySizeFeature? bodySize = + context.Features.Get(); + if (bodySize is { IsReadOnly: false }) + { + bodySize.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes; + } + + string operation = context.GetEndpoint()?.Metadata.GetMetadata() + ?.EndpointName ?? "Unmatched"; + bool healthEndpoint = operation is "GetLiveness" or "GetReadiness"; + bool acquired = healthEndpoint + ? protection.TryAcquireHealthIngress( + context.Connection.RemoteIpAddress, + out AbuseProtectionService.AbuseLease? lease, + out int retryAfterSeconds) + : protection.TryAcquireHttpIngress( + context.Connection.RemoteIpAddress, + operation, + out lease, + out retryAfterSeconds); + if (!acquired) + { + context.Response.Headers.RetryAfter = retryAfterSeconds.ToString( + System.Globalization.CultureInfo.InvariantCulture); + await WriteErrorAsync( + context, + StatusCodes.Status429TooManyRequests, + RendezvousErrorCode.RateLimited, + "The request rate limit was exceeded.", + retryAfterSeconds).ConfigureAwait(false); + return; + } + + using (lease) + { + if (context.Request.ContentLength > ContractLimits.HttpRequestMaxBytes) + { + await WriteErrorAsync( + context, + StatusCodes.Status413PayloadTooLarge, + RendezvousErrorCode.InvalidRequest, + "The request body exceeds the supported size.").ConfigureAwait(false); + return; + } + + await next(context).ConfigureAwait(false); + } + } + + private static Task WriteErrorAsync( + HttpContext context, + int status, + RendezvousErrorCode code, + string message, + int? retryAfterSeconds = null) + { + context.Response.StatusCode = status; + return context.Response.WriteAsJsonAsync( + new ApiError + { + Code = code, + Message = message, + RetryAfterSeconds = retryAfterSeconds, + }, + ContractJson.Options, + contentType: "application/json", + cancellationToken: context.RequestAborted); + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Abuse/TrustedProxyForwarding.cs b/src/FinalFactory.Rendezvous.Server/Abuse/TrustedProxyForwarding.cs new file mode 100644 index 0000000..1a90d6e --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Abuse/TrustedProxyForwarding.cs @@ -0,0 +1,24 @@ +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; + +namespace FinalFactory.Rendezvous.Server.Abuse; + +internal static class TrustedProxyForwarding +{ + public static bool IsEnabled(AbuseProtectionOptions options) => + options.TrustedProxyAddresses is { Length: > 0 }; + + public static void Configure( + ForwardedHeadersOptions forwarded, + AbuseProtectionOptions abuse) + { + forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor; + forwarded.ForwardLimit = 1; + forwarded.KnownProxies.Clear(); + forwarded.KnownIPNetworks.Clear(); + foreach (string address in abuse.TrustedProxyAddresses ?? []) + { + forwarded.KnownProxies.Add(IPAddress.Parse(address)); + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs index 2301c58..fb24fcb 100644 --- a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs +++ b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs @@ -1,5 +1,6 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.JoinAttempts; @@ -20,6 +21,7 @@ internal static class ContractEndpoints .Accepts("application/json") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status409Conflict) @@ -31,45 +33,54 @@ internal static class ContractEndpoints .Accepts("application/json") .Produces() .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status410Gone) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("RenewSessionLease"); sessions.MapPut("/{listingId}", UpdateSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("UpdateSession"); sessions.MapDelete("/{listingId}", DeleteSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("DeleteSession"); sessions.MapGet("/", BrowseSessions) .Produces() .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("BrowseSessions"); sessions.MapGet("/{listingId}", GetSession) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("GetSession"); sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("BrowseHostJoinAttempts"); @@ -80,6 +91,7 @@ internal static class ContractEndpoints .Accepts("application/json") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status410Gone) @@ -90,14 +102,17 @@ internal static class ContractEndpoints .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("CancelJoinAttempt"); attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) .Accepts("application/json") .Produces() .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("ReportConnectionOutcome"); @@ -109,6 +124,7 @@ internal static class ContractEndpoints [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, + [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) @@ -122,13 +138,29 @@ internal static class ContractEndpoints return AuthenticationRequired(httpContext); } - SessionServiceResult result = sessions.Register( - principal!, - request, - cancellationToken); - return result.Succeeded && result.Value is not null - ? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value) - : Error(result.Error); + IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "RegisterSession", + Tenant(publisher.GameId, publisher.EnvironmentId), + publisher.Subject, + null, + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + SessionServiceResult result = sessions.Register( + principal!, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value) + : Error(result.Error); + } } private static IResult RenewLease( @@ -137,6 +169,7 @@ internal static class ContractEndpoints [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, + [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) @@ -150,14 +183,30 @@ internal static class ContractEndpoints return AuthenticationRequired(httpContext); } - SessionServiceResult result = sessions.Renew( - principal!, - listingId, - request, - cancellationToken); - return result.Succeeded && result.Value is not null - ? Results.Ok(result.Value) - : Error(result.Error); + IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "RenewSessionLease", + Tenant(publisher.GameId, publisher.EnvironmentId), + publisher.Subject, + listingId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + SessionServiceResult result = sessions.Renew( + principal!, + listingId, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } } private static IResult UpdateSession( @@ -166,6 +215,7 @@ internal static class ContractEndpoints [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, + [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) @@ -179,12 +229,28 @@ internal static class ContractEndpoints return AuthenticationRequired(httpContext); } - SessionServiceResult result = sessions.Update( - principal!, - listingId, - request, - cancellationToken); - return result.Succeeded ? Results.NoContent() : Error(result.Error); + IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "UpdateSession", + Tenant(publisher.GameId, publisher.EnvironmentId), + publisher.Subject, + listingId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + SessionServiceResult result = sessions.Update( + principal!, + listingId, + request, + cancellationToken); + return result.Succeeded ? Results.NoContent() : Error(result.Error); + } } private static IResult DeleteSession( @@ -193,6 +259,7 @@ internal static class ContractEndpoints [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, + [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) @@ -206,12 +273,28 @@ internal static class ContractEndpoints return AuthenticationRequired(httpContext); } - SessionServiceResult result = sessions.Delete( - principal!, - listingId, - request, - cancellationToken); - return result.Succeeded ? Results.NoContent() : Error(result.Error); + IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "DeleteSession", + Tenant(publisher.GameId, publisher.EnvironmentId), + publisher.Subject, + listingId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + SessionServiceResult result = sessions.Delete( + principal!, + listingId, + request, + cancellationToken); + return result.Succeeded ? Results.NoContent() : Error(result.Error); + } } private static IResult BrowseSessions( @@ -224,6 +307,8 @@ internal static class ContractEndpoints [FromQuery] bool? excludeFull, [FromQuery] string? cursor, [FromServices] SessionBrowserService browser, + [FromServices] AbuseProtectionService abuseProtection, + HttpContext httpContext, CancellationToken cancellationToken) { if (!GameId.TryParse(gameId, out GameId parsedGameId) @@ -233,20 +318,35 @@ internal static class ContractEndpoints return Error(RendezvousErrorCode.InvalidRequest); } - BrowserServiceResult result = browser.Browse(new() + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "BrowseSessions", + Tenant(parsedGameId, parsedEnvironmentId), + null, + null, + out AbuseProtectionService.AbuseLease? abuseLease)) { - ContractVersion = contractVersion, - GameId = parsedGameId, - EnvironmentId = parsedEnvironmentId, - ProtocolVersion = protocolVersion, - RegionId = regionId is null ? null : new RegionId(regionId), - PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems, - ExcludeFull = excludeFull ?? false, - Cursor = cursor, - }, cancellationToken); - return result.Succeeded && result.Value is not null - ? Results.Ok(result.Value) - : Error(result.Error); + return RateLimited(httpContext); + } + + using (abuseLease) + { + BrowserServiceResult result = browser.Browse(new() + { + ContractVersion = contractVersion, + GameId = parsedGameId, + EnvironmentId = parsedEnvironmentId, + ProtocolVersion = protocolVersion, + RegionId = regionId is null ? null : new RegionId(regionId), + PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems, + ExcludeFull = excludeFull ?? false, + Cursor = cursor, + }, cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } } private static IResult GetSession( @@ -256,6 +356,8 @@ internal static class ContractEndpoints [FromQuery] string environmentId, [FromQuery] uint protocolVersion, [FromServices] SessionBrowserService browser, + [FromServices] AbuseProtectionService abuseProtection, + HttpContext httpContext, CancellationToken cancellationToken) { if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None) @@ -269,15 +371,30 @@ internal static class ContractEndpoints return Error(RendezvousErrorCode.InvalidRequest); } - BrowserServiceResult result = browser.Get( - listingId, - parsedGameId, - parsedEnvironmentId, - protocolVersion, - cancellationToken); - return result.Succeeded && result.Value is not null - ? Results.Ok(result.Value) - : Error(result.Error); + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "GetSession", + Tenant(parsedGameId, parsedEnvironmentId), + null, + listingId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + BrowserServiceResult result = browser.Get( + listingId, + parsedGameId, + parsedEnvironmentId, + protocolVersion, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } } private static IResult BrowseHostJoinAttempts( @@ -287,23 +404,41 @@ internal static class ContractEndpoints [FromQuery] int? pageSize, [FromQuery] string? cursor, [FromServices] JoinAttemptService attempts, + [FromServices] AbuseProtectionService abuseProtection, + HttpContext httpContext, CancellationToken cancellationToken) { - JoinAttemptServiceResult result = attempts.BrowseForHost( - listingId, - contractVersion, - leaseToken, - pageSize ?? ContractLimits.BrowserPageMaxItems, - cursor, - cancellationToken); - return result.Succeeded && result.Value is not null - ? Results.Ok(result.Value) - : Error(result.Error); + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "BrowseHostJoinAttempts", + null, + AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty), + listingId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + JoinAttemptServiceResult result = attempts.BrowseForHost( + listingId, + contractVersion, + leaseToken, + pageSize ?? ContractLimits.BrowserPageMaxItems, + cursor, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } } private static IResult CreateJoinAttempt( [FromBody] CreateJoinAttemptRequest request, [FromServices] JoinAttemptService attempts, + [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, CancellationToken cancellationToken) { @@ -313,26 +448,58 @@ internal static class ContractEndpoints } string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress); - JoinAttemptServiceResult result = attempts.Create( - clientSubject, - request, - cancellationToken); - return result.Succeeded && result.Value is not null - ? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value) - : Error(result.Error); + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "CreateJoinAttempt", + Tenant(request.GameId, request.EnvironmentId), + clientSubject, + request.ListingId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + JoinAttemptServiceResult result = attempts.Create( + clientSubject, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value) + : Error(result.Error); + } } private static IResult CancelJoinAttempt( JoinAttemptId attemptId, [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, [FromServices] JoinAttemptService attempts, + [FromServices] AbuseProtectionService abuseProtection, + HttpContext httpContext, CancellationToken cancellationToken) { - JoinAttemptServiceResult result = attempts.Cancel( - attemptId, - clientPunchCapability, - cancellationToken); - return result.Succeeded ? Results.NoContent() : Error(result.Error); + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "CancelJoinAttempt", + null, + AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty), + attemptId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + JoinAttemptServiceResult result = attempts.Cancel( + attemptId, + clientPunchCapability, + cancellationToken); + return result.Succeeded ? Results.NoContent() : Error(result.Error); + } } private static IResult ReportConnectionOutcome( @@ -340,16 +507,33 @@ internal static class ContractEndpoints [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, [FromBody] ReportConnectionOutcomeRequest request, [FromServices] ConnectionOutcomeService outcomes, + [FromServices] AbuseProtectionService abuseProtection, + HttpContext httpContext, CancellationToken cancellationToken) { - ConnectionOutcomeServiceResult result = outcomes.Report( - attemptId, - clientPunchCapability, - request, - cancellationToken); - return result.Succeeded && result.Value is not null - ? Results.Ok(result.Value) - : Error(result.Error); + if (!TryAcquireIdentity( + abuseProtection, + httpContext, + "ReportConnectionOutcome", + null, + AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty), + attemptId.ToString(), + out AbuseProtectionService.AbuseLease? abuseLease)) + { + return RateLimited(httpContext); + } + + using (abuseLease) + { + ConnectionOutcomeServiceResult result = outcomes.Report( + attemptId, + clientPunchCapability, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } } private static bool TryAuthenticatePublisher( @@ -377,11 +561,41 @@ internal static class ContractEndpoints return true; } - private static IResult Error(RendezvousErrorCode code) => Results.Json( + private static bool TryAcquireIdentity( + AbuseProtectionService abuseProtection, + HttpContext httpContext, + string operation, + string? tenant, + string? principal, + string? resource, + out AbuseProtectionService.AbuseLease? lease) + { + if (abuseProtection.TryAcquireHttpIdentity( + operation, + httpContext.Connection.RemoteIpAddress, + tenant, + principal, + resource, + out lease, + out int retryAfterSeconds)) + { + return true; + } + + httpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString( + System.Globalization.CultureInfo.InvariantCulture); + return false; + } + + private static string Tenant(GameId gameId, EnvironmentId environmentId) => + $"{gameId.Value}/{environmentId.Value}"; + + private static IResult Error(RendezvousErrorCode code, int? retryAfterSeconds = null) => Results.Json( new ApiError { Code = code, Message = ErrorMessage(code), + RetryAfterSeconds = retryAfterSeconds, }, ContractJson.Options, statusCode: ErrorStatus(code)); @@ -392,6 +606,18 @@ internal static class ContractEndpoints return Error(RendezvousErrorCode.AuthenticationRequired); } + private static IResult RateLimited(HttpContext context) + { + int? retryAfterSeconds = int.TryParse( + context.Response.Headers.RetryAfter, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out int parsed) + ? Math.Clamp(parsed, 1, 60) + : null; + return Error(RendezvousErrorCode.RateLimited, retryAfterSeconds); + } + private static int ErrorStatus(RendezvousErrorCode code) => code switch { RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized, @@ -417,6 +643,7 @@ internal static class ContractEndpoints RendezvousErrorCode.Expired => "The session lease has expired.", RendezvousErrorCode.StaleHost => "The session has no fresh host presence.", RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.", + RendezvousErrorCode.RateLimited => "The request rate limit was exceeded.", RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.", RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.", RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.", diff --git a/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs b/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs index 9ee41e4..2cf223b 100644 --- a/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs +++ b/src/FinalFactory.Rendezvous.Server/Http/RendezvousExceptionHandler.cs @@ -17,18 +17,26 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler } bool invalidRequest = exception is BadHttpRequestException or JsonException; - httpContext.Response.StatusCode = invalidRequest - ? StatusCodes.Status400BadRequest - : StatusCodes.Status500InternalServerError; + bool payloadTooLarge = exception is BadHttpRequestException + { + StatusCode: StatusCodes.Status413PayloadTooLarge, + }; + httpContext.Response.StatusCode = payloadTooLarge + ? StatusCodes.Status413PayloadTooLarge + : invalidRequest + ? StatusCodes.Status400BadRequest + : StatusCodes.Status500InternalServerError; await httpContext.Response.WriteAsJsonAsync( new ApiError { Code = invalidRequest ? RendezvousErrorCode.InvalidRequest : RendezvousErrorCode.InternalError, - Message = invalidRequest - ? "The request body, route, or query value is invalid." - : "The service could not complete the request.", + Message = payloadTooLarge + ? "The request body exceeds the supported size." + : invalidRequest + ? "The request body, route, or query value is invalid." + : "The service could not complete the request.", }, ContractJson.Options, cancellationToken).ConfigureAwait(false); diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs index 2c59f4b..1cd6180 100644 --- a/src/FinalFactory.Rendezvous.Server/Program.cs +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -1,5 +1,6 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.Http; @@ -8,6 +9,7 @@ using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.Transport; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.OpenApi; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -96,6 +98,31 @@ builder.Services.AddOpenApi("v1", static options => [attemptReference] = [], }); } + + foreach (OpenApiOperation operation in path.Operations.Values) + { + if (operation.Responses is null + || !operation.Responses.TryGetValue( + StatusCodes.Status429TooManyRequests.ToString( + System.Globalization.CultureInfo.InvariantCulture), + out IOpenApiResponse? response) + || response is not OpenApiResponse concreteResponse) + { + continue; + } + + concreteResponse.Headers ??= + new Dictionary(StringComparer.OrdinalIgnoreCase); + concreteResponse.Headers["Retry-After"] = new OpenApiHeader + { + Description = "Whole seconds before the caller should retry (1-60).", + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Integer, + Format = "int32", + }, + }; + } } return Task.CompletedTask; @@ -107,6 +134,45 @@ builder.Services.Configure(static options => options.ThrowOnBadRequest = true); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); +builder.WebHost.ConfigureKestrel(static options => + options.Limits.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes); + +builder.Services + .AddOptions() + .BindConfiguration(AbuseProtectionOptions.SectionName) + .ValidateDataAnnotations() + .Validate( + options => options.HttpOptionalRequestsPerWindow + < options.HttpGlobalRequestsPerWindow, + "The optional HTTP request budget must leave global capacity for lease operations.") + .Validate( + options => options.HttpOptionalConcurrency < options.HttpGlobalConcurrency, + "The optional HTTP concurrency budget must leave global capacity for lease operations.") + .Validate( + options => options.HttpOptionalIpPrefixRequestsPerWindow + < options.HttpIpPrefixRequestsPerWindow, + "The optional HTTP source budget must leave capacity for lease operations.") + .Validate( + options => options.HttpOptionalIpPrefixConcurrency + < options.HttpIpPrefixConcurrency, + "The optional HTTP source concurrency must leave capacity for lease operations.") + .Validate( + options => options.CriticalTrackedKeyReserve >= 16 + && options.UdpTrackedKeyLimit + options.CriticalTrackedKeyReserve + < options.MaxTrackedKeys, + "The tracked-key reserve must leave at least 16 keys for critical operations.") + .Validate( + options => options.TrustedProxyAddresses is { Length: <= 32 } addresses + && addresses.All( + static value => IPAddress.TryParse(value, out _)), + "Trusted proxy addresses must contain at most 32 literal IP addresses.") + .ValidateOnStart(); +builder.Services.AddSingleton(); +AbuseProtectionOptions configuredAbuseProtection = builder.Configuration + .GetSection(AbuseProtectionOptions.SectionName) + .Get() ?? new AbuseProtectionOptions(); +builder.Services.Configure(options => + TrustedProxyForwarding.Configure(options, configuredAbuseProtection)); SystemRendezvousClock rendezvousClock = new(); EphemeralStoreOptions stateOptions = new(); @@ -176,13 +242,19 @@ if (!isOpenApiGeneration) WebApplication app = builder.Build(); app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain()); +if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection)) +{ + app.UseForwardedHeaders(); +} app.UseExceptionHandler(); +app.UseMiddleware(); app.MapOpenApi(); app.MapRendezvousContractEndpoints(); app.MapGet( "/health/live", static () => Results.Ok(new HealthResponse { Status = "live" })) .Produces() + .Produces(StatusCodes.Status429TooManyRequests) .WithName("GetLiveness") .WithTags("Health"); app.MapGet( @@ -198,6 +270,7 @@ app.MapGet( ? Results.StatusCode(StatusCodes.Status503ServiceUnavailable) : Results.Ok(new HealthResponse { Status = "ready" })) .Produces() + .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("GetReadiness") .WithTags("Health"); diff --git a/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs b/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs index 5b4d0bc..83cc448 100644 --- a/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs +++ b/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Sockets; using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; @@ -36,7 +37,8 @@ internal enum NatMediationResult internal sealed class NatMediationProcessor( IEphemeralRendezvousStore store, ISessionCapabilityService capabilities, - JoinAttemptService joinAttempts) + JoinAttemptService joinAttempts, + AbuseProtectionService? abuseProtection = null) { public NatMediationResult ProcessDatagram( ReadOnlySpan encoded, @@ -44,6 +46,29 @@ internal sealed class NatMediationProcessor( INatIntroductionSink introductionSink, CancellationToken cancellationToken = default) { + if (!TryAcceptIngress(observedPublicEndpoint, "frozen")) + { + return NatMediationResult.Dropped; + } + + return ProcessDatagramAfterIngress( + encoded, + observedPublicEndpoint, + introductionSink, + cancellationToken); + } + + internal bool TryAcceptIngress(IPEndPoint observedPublicEndpoint, string operation) => + abuseProtection is null + || abuseProtection.TryAcceptUdpIngress(observedPublicEndpoint.Address, operation); + + internal NatMediationResult ProcessDatagramAfterIngress( + ReadOnlySpan encoded, + IPEndPoint observedPublicEndpoint, + INatIntroductionSink introductionSink, + CancellationToken cancellationToken = default) + { + if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _) || datagram is null || datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters @@ -63,7 +88,7 @@ internal sealed class NatMediationProcessor( return NatMediationResult.Dropped; } - NatMediationResult result = ProcessRequest( + NatMediationResult result = ProcessRequestCore( claimedLocalEndpoint, observedPublicEndpoint, NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability), @@ -76,7 +101,7 @@ internal sealed class NatMediationProcessor( return result; } - return ProcessRequest( + return ProcessRequestCore( claimedLocalEndpoint, observedPublicEndpoint, NatPunchRequestTokenCodec.Encode( @@ -98,6 +123,39 @@ internal sealed class NatMediationProcessor( ArgumentNullException.ThrowIfNull(observedPublicEndpoint); ArgumentNullException.ThrowIfNull(introductionSink); + if (!TryAcceptIngress(observedPublicEndpoint, "litenet-or-invalid")) + { + return NatMediationResult.Dropped; + } + + return ProcessRequestAfterIngress( + claimedLocalEndpoint, + observedPublicEndpoint, + token, + introductionSink, + cancellationToken); + } + + internal NatMediationResult ProcessRequestAfterIngress( + IPEndPoint claimedLocalEndpoint, + IPEndPoint observedPublicEndpoint, + string token, + INatIntroductionSink introductionSink, + CancellationToken cancellationToken = default) => ProcessRequestCore( + claimedLocalEndpoint, + observedPublicEndpoint, + token, + introductionSink, + cancellationToken); + + private NatMediationResult ProcessRequestCore( + IPEndPoint claimedLocalEndpoint, + IPEndPoint observedPublicEndpoint, + string token, + INatIntroductionSink introductionSink, + CancellationToken cancellationToken) + { + if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request) || request is null || !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint) @@ -106,6 +164,17 @@ internal sealed class NatMediationProcessor( return NatMediationResult.Dropped; } + string operation = request.Role.ToString(); + if (abuseProtection is not null + && !abuseProtection.TryAcceptUdpIdentity( + operation, + observedPublicEndpoint.Address, + request.Capability, + request.MediationHandle.ToString())) + { + return NatMediationResult.Dropped; + } + ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate( claimedLocalEndpoint, publicEndpoint.AddressFamily, diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs index 2aeac89..3a60163 100644 --- a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs +++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs @@ -172,12 +172,21 @@ internal sealed partial class UdpMediatorService : BackgroundService bool isFrozenEnvelope = length >= 2 && data[0] == RendezvousUdpCodec.MagicFirst && data[1] == RendezvousUdpCodec.MagicSecond; + if (!processor.TryAcceptIngress( + endPoint, + isFrozenEnvelope ? "frozen" : "litenet-or-invalid")) + { + Drop(ref length); + return; + } + INatIntroductionSink? sink = _sink; if (isFrozenEnvelope) { if (sink is not null) { - _ = processor.ProcessDatagram(data.AsSpan(0, length), endPoint, sink); + _ = processor.ProcessDatagramAfterIngress( + data.AsSpan(0, length), endPoint, sink); } } else if (sink is not null @@ -188,7 +197,8 @@ internal sealed partial class UdpMediatorService : BackgroundService && claimedLocalEndpoint is not null && token is not null) { - _ = processor.ProcessRequest(claimedLocalEndpoint, endPoint, token, sink); + _ = processor.ProcessRequestAfterIngress( + claimedLocalEndpoint, endPoint, token, sink); } // Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions. diff --git a/src/FinalFactory.Rendezvous.Server/appsettings.json b/src/FinalFactory.Rendezvous.Server/appsettings.json index c286692..08dc57a 100644 --- a/src/FinalFactory.Rendezvous.Server/appsettings.json +++ b/src/FinalFactory.Rendezvous.Server/appsettings.json @@ -5,6 +5,38 @@ "Port": 9050, "MaxDatagramsPerPoll": 256, "PollIntervalMilliseconds": 2 + }, + "AbuseProtection": { + "WindowSeconds": 1, + "MaxTrackedKeys": 100000, + "CriticalTrackedKeyReserve": 2048, + "UdpTrackedKeyLimit": 70000, + "TrustedProxyAddresses": [], + "HealthGlobalRequestsPerWindow": 1000, + "HealthGlobalConcurrency": 32, + "HealthIpPrefixRequestsPerWindow": 120, + "HealthIpPrefixConcurrency": 8, + "HttpGlobalRequestsPerWindow": 20000, + "HttpOptionalRequestsPerWindow": 18000, + "HttpIpPrefixRequestsPerWindow": 500, + "HttpOptionalIpPrefixRequestsPerWindow": 450, + "HttpOperationRequestsPerWindow": 5000, + "HttpTenantRequestsPerWindow": 2000, + "HttpPrincipalRequestsPerWindow": 500, + "HttpResourceRequestsPerWindow": 200, + "HttpGlobalConcurrency": 1024, + "HttpOptionalConcurrency": 768, + "HttpIpPrefixConcurrency": 64, + "HttpOptionalIpPrefixConcurrency": 48, + "HttpOperationConcurrency": 256, + "HttpTenantConcurrency": 256, + "HttpPrincipalConcurrency": 32, + "HttpResourceConcurrency": 16, + "UdpGlobalDatagramsPerWindow": 100000, + "UdpIpPrefixDatagramsPerWindow": 2000, + "UdpOperationDatagramsPerWindow": 50000, + "UdpCapabilityDatagramsPerWindow": 120, + "UdpResourceDatagramsPerWindow": 240 } }, "Logging": { diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs index 84cdb94..ce14313 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs @@ -1,5 +1,6 @@ using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Provisioning; @@ -159,6 +160,8 @@ public sealed class RendezvousClientIntegrationTests options.ThrowOnBadRequest = true); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); + builder.Services.AddOptions(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.PublisherAuthorization); @@ -173,6 +176,7 @@ public sealed class RendezvousClientIntegrationTests WebApplication app = builder.Build(); app.UseExceptionHandler(); + app.UseMiddleware(); app.MapRendezvousContractEndpoints(); await app.StartAsync(); IServer server = app.Services.GetRequiredService(); diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs index 0db83a3..798b12e 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs @@ -149,5 +149,45 @@ public sealed class OpenApiCompatibilityTests parameter.GetProperty("in").GetString() == "header" && parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token"); Assert.True(leaseToken.GetProperty("required").GetBoolean()); + + int overloadContracts = 0; + foreach (JsonProperty pathItem in root.GetProperty("paths").EnumerateObject()) + { + foreach (JsonProperty operation in pathItem.Value.EnumerateObject().Where( + static item => item.Name is "get" or "post" or "put" or "delete")) + { + JsonElement responses = operation.Value.GetProperty("responses"); + if (!responses.TryGetProperty("429", out JsonElement overloaded)) + { + continue; + } + + overloadContracts++; + JsonElement retryAfter = overloaded.GetProperty("headers") + .GetProperty("Retry-After"); + Assert.Equal( + "integer", + retryAfter.GetProperty("schema").GetProperty("type").GetString()); + } + } + + Assert.Equal(12, overloadContracts); + (string Path, string Method)[] bodyOperations = + [ + ("/v1/sessions", "post"), + ("/v1/sessions/{listingId}/renew", "post"), + ("/v1/sessions/{listingId}", "put"), + ("/v1/sessions/{listingId}", "delete"), + ("/v1/join-attempts", "post"), + ("/v1/join-attempts/{attemptId}/outcome", "post"), + ]; + foreach ((string operationPath, string method) in bodyOperations) + { + Assert.True(root.GetProperty("paths") + .GetProperty(operationPath) + .GetProperty(method) + .GetProperty("responses") + .TryGetProperty("413", out _)); + } } } diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs index 2b50968..18ad84c 100644 --- a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.Http; @@ -304,6 +305,8 @@ public sealed class JoinAttemptHttpEndpointTests options.ThrowOnBadRequest = true); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); + builder.Services.AddOptions(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning.Policies); builder.Services.AddSingleton(provisioning.Credentials); @@ -324,6 +327,7 @@ public sealed class JoinAttemptHttpEndpointTests WebApplication app = builder.Build(); app.UseExceptionHandler(); + app.UseMiddleware(); app.MapRendezvousContractEndpoints(); await app.StartAsync(); IServer server = app.Services.GetRequiredService(); diff --git a/tests/FinalFactory.Rendezvous.Tests/Server/Abuse/AbuseProtectionTests.cs b/tests/FinalFactory.Rendezvous.Tests/Server/Abuse/AbuseProtectionTests.cs new file mode 100644 index 0000000..a0c8ae9 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Server/Abuse/AbuseProtectionTests.cs @@ -0,0 +1,472 @@ +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( + 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