Compare commits

...

1 Commits

Author SHA1 Message Date
KyuubiYoru 88ef946af5 feat(server): harden hostile input and overload behavior (#15)
quality-gate / quality (push) Failing after 1m5s
2026-07-16 12:37:38 +02:00
22 changed files with 2159 additions and 93 deletions
+2
View File
@@ -89,6 +89,8 @@ The frozen v1 wire surface is documented in the
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md). [HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
Tenant policy, publisher/operator principals, and production key custody are Tenant policy, publisher/operator principals, and production key custody are
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md). 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 The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md). documented in the [TestClient integration guide](docs/integration/test-client.md).
The always-on three-party scenarios, optional Linux namespace topology, and The always-on three-party scenarios, optional Linux namespace topology, and
+268
View File
@@ -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": { "503": {
"description": "Service Unavailable" "description": "Service Unavailable"
} }
@@ -85,6 +123,16 @@
} }
} }
}, },
"413": {
"description": "Payload Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": { "401": {
"description": "Unauthorized", "description": "Unauthorized",
"content": { "content": {
@@ -127,6 +175,15 @@
}, },
"429": { "429": {
"description": "Too Many Requests", "description": "Too Many Requests",
"headers": {
"Retry-After": {
"description": "Whole seconds before the caller should retry (1-60).",
"schema": {
"type": "integer",
"format": "int32"
}
}
},
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "content": {
@@ -303,6 +379,16 @@
} }
} }
}, },
"413": {
"description": "Payload Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": { "401": {
"description": "Unauthorized", "description": "Unauthorized",
"content": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "content": {
@@ -411,6 +516,16 @@
} }
} }
}, },
"413": {
"description": "Payload Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": { "401": {
"description": "Unauthorized", "description": "Unauthorized",
"content": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "content": {
@@ -497,6 +631,16 @@
} }
} }
}, },
"413": {
"description": "Payload Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": { "401": {
"description": "Unauthorized", "description": "Unauthorized",
"content": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "content": {
@@ -756,6 +957,16 @@
} }
} }
}, },
"413": {
"description": "Payload Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": { "404": {
"description": "Not Found", "description": "Not Found",
"content": { "content": {
@@ -788,6 +999,15 @@
}, },
"429": { "429": {
"description": "Too Many Requests", "description": "Too Many Requests",
"headers": {
"Retry-After": {
"description": "Whole seconds before the caller should retry (1-60).",
"schema": {
"type": "integer",
"format": "int32"
}
}
},
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "content": {
@@ -930,6 +1169,16 @@
} }
} }
}, },
"413": {
"description": "Payload Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": { "404": {
"description": "Not Found", "description": "Not Found",
"content": { "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": { "503": {
"description": "Service Unavailable", "description": "Service Unavailable",
"content": { "content": {
+98
View File
@@ -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.
+1 -1
View File
@@ -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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. |
@@ -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;
}
@@ -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<AbuseProtectionOptions> 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<RateDimension> rates =
[
new("http:rate:global", _options.HttpGlobalRequestsPerWindow),
new($"http:rate:ip:{prefix}", _options.HttpIpPrefixRequestsPerWindow),
new($"http:rate:operation:{operation}", _options.HttpOperationRequestsPerWindow),
];
List<RateDimension> 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<RateDimension> rates = [];
List<RateDimension> 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<RateDimension> rateDimensions,
List<RateDimension> 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<byte> encoded = byteCount <= 1_024
? stackalloc byte[byteCount]
: (rented = ArrayPool<byte>.Shared.Rent(byteCount)).AsSpan(0, byteCount);
Span<byte> 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<byte>.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<RateDimension> rates,
ReadOnlySpan<RateDimension> 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<string, int> counts,
ReadOnlySpan<RateDimension> 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<string, int> WindowCounts { get; } = new(StringComparer.Ordinal);
public Dictionary<string, int> 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);
}
}
}
}
@@ -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<IHttpMaxRequestBodySizeFeature>();
if (bodySize is { IsReadOnly: false })
{
bodySize.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes;
}
string operation = context.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()
?.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);
}
}
@@ -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));
}
}
}
@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
@@ -20,6 +21,7 @@ internal static class ContractEndpoints
.Accepts<RegisterSessionRequest>("application/json") .Accepts<RegisterSessionRequest>("application/json")
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created) .Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
@@ -31,45 +33,54 @@ internal static class ContractEndpoints
.Accepts<RenewLeaseRequest>("application/json") .Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>() .Produces<RenewLeaseResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone) .Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RenewSessionLease"); .WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession) sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json") .Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("UpdateSession"); .WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession) sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json") .Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized) .Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden) .Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("DeleteSession"); .WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions) sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>() .Produces<BrowseSessionsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions"); .WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession) sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>() .Produces<GetSessionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("GetSession"); .WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>() .Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseHostJoinAttempts"); .WithName("BrowseHostJoinAttempts");
@@ -80,6 +91,7 @@ internal static class ContractEndpoints
.Accepts<CreateJoinAttemptRequest>("application/json") .Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created) .Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone) .Produces<ApiError>(StatusCodes.Status410Gone)
@@ -90,14 +102,17 @@ internal static class ContractEndpoints
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CancelJoinAttempt"); .WithName("CancelJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json") .Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>() .Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("ReportConnectionOutcome"); .WithName("ReportConnectionOutcome");
@@ -109,6 +124,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -122,6 +138,21 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
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<RegisterSessionResponse> result = sessions.Register( SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
principal!, principal!,
request, request,
@@ -130,6 +161,7 @@ internal static class ContractEndpoints
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value) ? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
: Error(result.Error); : Error(result.Error);
} }
}
private static IResult RenewLease( private static IResult RenewLease(
SessionListingId listingId, SessionListingId listingId,
@@ -137,6 +169,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -150,6 +183,21 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
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<RenewLeaseResponse> result = sessions.Renew( SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
principal!, principal!,
listingId, listingId,
@@ -159,6 +207,7 @@ internal static class ContractEndpoints
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Error(result.Error); : Error(result.Error);
} }
}
private static IResult UpdateSession( private static IResult UpdateSession(
SessionListingId listingId, SessionListingId listingId,
@@ -166,6 +215,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -179,6 +229,21 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
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<bool> result = sessions.Update( SessionServiceResult<bool> result = sessions.Update(
principal!, principal!,
listingId, listingId,
@@ -186,6 +251,7 @@ internal static class ContractEndpoints
cancellationToken); cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error); return result.Succeeded ? Results.NoContent() : Error(result.Error);
} }
}
private static IResult DeleteSession( private static IResult DeleteSession(
SessionListingId listingId, SessionListingId listingId,
@@ -193,6 +259,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader, [FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials, [FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions, [FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock, [FromServices] IWallClock clock,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -206,6 +273,21 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext); return AuthenticationRequired(httpContext);
} }
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<bool> result = sessions.Delete( SessionServiceResult<bool> result = sessions.Delete(
principal!, principal!,
listingId, listingId,
@@ -213,6 +295,7 @@ internal static class ContractEndpoints
cancellationToken); cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error); return result.Succeeded ? Results.NoContent() : Error(result.Error);
} }
}
private static IResult BrowseSessions( private static IResult BrowseSessions(
[FromQuery] int contractVersion, [FromQuery] int contractVersion,
@@ -224,6 +307,8 @@ internal static class ContractEndpoints
[FromQuery] bool? excludeFull, [FromQuery] bool? excludeFull,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromServices] SessionBrowserService browser, [FromServices] SessionBrowserService browser,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!GameId.TryParse(gameId, out GameId parsedGameId) if (!GameId.TryParse(gameId, out GameId parsedGameId)
@@ -233,6 +318,20 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.InvalidRequest); return Error(RendezvousErrorCode.InvalidRequest);
} }
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"BrowseSessions",
Tenant(parsedGameId, parsedEnvironmentId),
null,
null,
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new() BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
{ {
ContractVersion = contractVersion, ContractVersion = contractVersion,
@@ -248,6 +347,7 @@ internal static class ContractEndpoints
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Error(result.Error); : Error(result.Error);
} }
}
private static IResult GetSession( private static IResult GetSession(
SessionListingId listingId, SessionListingId listingId,
@@ -256,6 +356,8 @@ internal static class ContractEndpoints
[FromQuery] string environmentId, [FromQuery] string environmentId,
[FromQuery] uint protocolVersion, [FromQuery] uint protocolVersion,
[FromServices] SessionBrowserService browser, [FromServices] SessionBrowserService browser,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None) if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
@@ -269,6 +371,20 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.InvalidRequest); return Error(RendezvousErrorCode.InvalidRequest);
} }
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"GetSession",
Tenant(parsedGameId, parsedEnvironmentId),
null,
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
BrowserServiceResult<GetSessionResponse> result = browser.Get( BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId, listingId,
parsedGameId, parsedGameId,
@@ -279,6 +395,7 @@ internal static class ContractEndpoints
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Error(result.Error); : Error(result.Error);
} }
}
private static IResult BrowseHostJoinAttempts( private static IResult BrowseHostJoinAttempts(
SessionListingId listingId, SessionListingId listingId,
@@ -287,7 +404,23 @@ internal static class ContractEndpoints
[FromQuery] int? pageSize, [FromQuery] int? pageSize,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromServices] JoinAttemptService attempts, [FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"BrowseHostJoinAttempts",
null,
AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty),
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{ {
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost( JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
listingId, listingId,
@@ -300,10 +433,12 @@ internal static class ContractEndpoints
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Error(result.Error); : Error(result.Error);
} }
}
private static IResult CreateJoinAttempt( private static IResult CreateJoinAttempt(
[FromBody] CreateJoinAttemptRequest request, [FromBody] CreateJoinAttemptRequest request,
[FromServices] JoinAttemptService attempts, [FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext, HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -313,6 +448,20 @@ internal static class ContractEndpoints
} }
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress); string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"CreateJoinAttempt",
Tenant(request.GameId, request.EnvironmentId),
clientSubject,
request.ListingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create( JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
clientSubject, clientSubject,
request, request,
@@ -321,12 +470,29 @@ internal static class ContractEndpoints
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value) ? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
: Error(result.Error); : Error(result.Error);
} }
}
private static IResult CancelJoinAttempt( private static IResult CancelJoinAttempt(
JoinAttemptId attemptId, JoinAttemptId attemptId,
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromServices] JoinAttemptService attempts, [FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"CancelJoinAttempt",
null,
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
attemptId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{ {
JoinAttemptServiceResult<bool> result = attempts.Cancel( JoinAttemptServiceResult<bool> result = attempts.Cancel(
attemptId, attemptId,
@@ -334,13 +500,30 @@ internal static class ContractEndpoints
cancellationToken); cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error); return result.Succeeded ? Results.NoContent() : Error(result.Error);
} }
}
private static IResult ReportConnectionOutcome( private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId, JoinAttemptId attemptId,
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromBody] ReportConnectionOutcomeRequest request, [FromBody] ReportConnectionOutcomeRequest request,
[FromServices] ConnectionOutcomeService outcomes, [FromServices] ConnectionOutcomeService outcomes,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{
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( ConnectionOutcomeServiceResult result = outcomes.Report(
attemptId, attemptId,
@@ -351,6 +534,7 @@ internal static class ContractEndpoints
? Results.Ok(result.Value) ? Results.Ok(result.Value)
: Error(result.Error); : Error(result.Error);
} }
}
private static bool TryAuthenticatePublisher( private static bool TryAuthenticatePublisher(
string? authorizationHeader, string? authorizationHeader,
@@ -377,11 +561,41 @@ internal static class ContractEndpoints
return true; 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 new ApiError
{ {
Code = code, Code = code,
Message = ErrorMessage(code), Message = ErrorMessage(code),
RetryAfterSeconds = retryAfterSeconds,
}, },
ContractJson.Options, ContractJson.Options,
statusCode: ErrorStatus(code)); statusCode: ErrorStatus(code));
@@ -392,6 +606,18 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.AuthenticationRequired); 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 private static int ErrorStatus(RendezvousErrorCode code) => code switch
{ {
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized, RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
@@ -417,6 +643,7 @@ internal static class ContractEndpoints
RendezvousErrorCode.Expired => "The session lease has expired.", RendezvousErrorCode.Expired => "The session lease has expired.",
RendezvousErrorCode.StaleHost => "The session has no fresh host presence.", RendezvousErrorCode.StaleHost => "The session has no fresh host presence.",
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.", 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.CapacityExceeded => "The configured session capacity is currently exhausted.",
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.", RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.", RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
@@ -17,7 +17,13 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
} }
bool invalidRequest = exception is BadHttpRequestException or JsonException; bool invalidRequest = exception is BadHttpRequestException or JsonException;
httpContext.Response.StatusCode = invalidRequest bool payloadTooLarge = exception is BadHttpRequestException
{
StatusCode: StatusCodes.Status413PayloadTooLarge,
};
httpContext.Response.StatusCode = payloadTooLarge
? StatusCodes.Status413PayloadTooLarge
: invalidRequest
? StatusCodes.Status400BadRequest ? StatusCodes.Status400BadRequest
: StatusCodes.Status500InternalServerError; : StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync( await httpContext.Response.WriteAsJsonAsync(
@@ -26,7 +32,9 @@ internal sealed class RendezvousExceptionHandler : IExceptionHandler
Code = invalidRequest Code = invalidRequest
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.InternalError, : RendezvousErrorCode.InternalError,
Message = invalidRequest Message = payloadTooLarge
? "The request body exceeds the supported size."
: invalidRequest
? "The request body, route, or query value is invalid." ? "The request body, route, or query value is invalid."
: "The service could not complete the request.", : "The service could not complete the request.",
}, },
@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
@@ -8,6 +9,7 @@ using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.OpenApi; using Microsoft.OpenApi;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -96,6 +98,31 @@ builder.Services.AddOpenApi("v1", static options =>
[attemptReference] = [], [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<string, IOpenApiHeader>(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; return Task.CompletedTask;
@@ -107,6 +134,45 @@ builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.WebHost.ConfigureKestrel(static options =>
options.Limits.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes);
builder.Services
.AddOptions<AbuseProtectionOptions>()
.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<AbuseProtectionService>();
AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
.GetSection(AbuseProtectionOptions.SectionName)
.Get<AbuseProtectionOptions>() ?? new AbuseProtectionOptions();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
SystemRendezvousClock rendezvousClock = new(); SystemRendezvousClock rendezvousClock = new();
EphemeralStoreOptions stateOptions = new(); EphemeralStoreOptions stateOptions = new();
@@ -176,13 +242,19 @@ if (!isOpenApiGeneration)
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain()); app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
{
app.UseForwardedHeaders();
}
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapOpenApi(); app.MapOpenApi();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
app.MapGet( app.MapGet(
"/health/live", "/health/live",
static () => Results.Ok(new HealthResponse { Status = "live" })) static () => Results.Ok(new HealthResponse { Status = "live" }))
.Produces<HealthResponse>() .Produces<HealthResponse>()
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.WithName("GetLiveness") .WithName("GetLiveness")
.WithTags("Health"); .WithTags("Health");
app.MapGet( app.MapGet(
@@ -198,6 +270,7 @@ app.MapGet(
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable) ? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new HealthResponse { Status = "ready" })) : Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>() .Produces<HealthResponse>()
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces(StatusCodes.Status503ServiceUnavailable) .Produces(StatusCodes.Status503ServiceUnavailable)
.WithName("GetReadiness") .WithName("GetReadiness")
.WithTags("Health"); .WithTags("Health");
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
@@ -36,7 +37,8 @@ internal enum NatMediationResult
internal sealed class NatMediationProcessor( internal sealed class NatMediationProcessor(
IEphemeralRendezvousStore store, IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities, ISessionCapabilityService capabilities,
JoinAttemptService joinAttempts) JoinAttemptService joinAttempts,
AbuseProtectionService? abuseProtection = null)
{ {
public NatMediationResult ProcessDatagram( public NatMediationResult ProcessDatagram(
ReadOnlySpan<byte> encoded, ReadOnlySpan<byte> encoded,
@@ -44,6 +46,29 @@ internal sealed class NatMediationProcessor(
INatIntroductionSink introductionSink, INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default) 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<byte> encoded,
IPEndPoint observedPublicEndpoint,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _) if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|| datagram is null || datagram is null
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters || datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
@@ -63,7 +88,7 @@ internal sealed class NatMediationProcessor(
return NatMediationResult.Dropped; return NatMediationResult.Dropped;
} }
NatMediationResult result = ProcessRequest( NatMediationResult result = ProcessRequestCore(
claimedLocalEndpoint, claimedLocalEndpoint,
observedPublicEndpoint, observedPublicEndpoint,
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability), NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
@@ -76,7 +101,7 @@ internal sealed class NatMediationProcessor(
return result; return result;
} }
return ProcessRequest( return ProcessRequestCore(
claimedLocalEndpoint, claimedLocalEndpoint,
observedPublicEndpoint, observedPublicEndpoint,
NatPunchRequestTokenCodec.Encode( NatPunchRequestTokenCodec.Encode(
@@ -98,6 +123,39 @@ internal sealed class NatMediationProcessor(
ArgumentNullException.ThrowIfNull(observedPublicEndpoint); ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
ArgumentNullException.ThrowIfNull(introductionSink); 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) if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|| request is null || request is null
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint) || !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
@@ -106,6 +164,17 @@ internal sealed class NatMediationProcessor(
return NatMediationResult.Dropped; 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( ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
claimedLocalEndpoint, claimedLocalEndpoint,
publicEndpoint.AddressFamily, publicEndpoint.AddressFamily,
@@ -172,12 +172,21 @@ internal sealed partial class UdpMediatorService : BackgroundService
bool isFrozenEnvelope = length >= 2 bool isFrozenEnvelope = length >= 2
&& data[0] == RendezvousUdpCodec.MagicFirst && data[0] == RendezvousUdpCodec.MagicFirst
&& data[1] == RendezvousUdpCodec.MagicSecond; && data[1] == RendezvousUdpCodec.MagicSecond;
if (!processor.TryAcceptIngress(
endPoint,
isFrozenEnvelope ? "frozen" : "litenet-or-invalid"))
{
Drop(ref length);
return;
}
INatIntroductionSink? sink = _sink; INatIntroductionSink? sink = _sink;
if (isFrozenEnvelope) if (isFrozenEnvelope)
{ {
if (sink is not null) 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 else if (sink is not null
@@ -188,7 +197,8 @@ internal sealed partial class UdpMediatorService : BackgroundService
&& claimedLocalEndpoint is not null && claimedLocalEndpoint is not null
&& token 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. // Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
@@ -5,6 +5,38 @@
"Port": 9050, "Port": 9050,
"MaxDatagramsPerPoll": 256, "MaxDatagramsPerPoll": 256,
"PollIntervalMilliseconds": 2 "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": { "Logging": {
@@ -1,5 +1,6 @@
using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
@@ -159,6 +160,8 @@ public sealed class RendezvousClientIntegrationTests
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddOptions<AbuseProtectionOptions>();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization); builder.Services.AddSingleton(provisioning.PublisherAuthorization);
@@ -173,6 +176,7 @@ public sealed class RendezvousClientIntegrationTests
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
await app.StartAsync(); await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>(); IServer server = app.Services.GetRequiredService<IServer>();
@@ -149,5 +149,45 @@ public sealed class OpenApiCompatibilityTests
parameter.GetProperty("in").GetString() == "header" parameter.GetProperty("in").GetString() == "header"
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token"); && parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
Assert.True(leaseToken.GetProperty("required").GetBoolean()); 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 _));
}
} }
} }
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
@@ -304,6 +305,8 @@ public sealed class JoinAttemptHttpEndpointTests
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddOptions<AbuseProtectionOptions>();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies); builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.Credentials);
@@ -324,6 +327,7 @@ public sealed class JoinAttemptHttpEndpointTests
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
await app.StartAsync(); await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>(); IServer server = app.Services.GetRequiredService<IServer>();
@@ -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<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;
}
}
@@ -1,15 +1,56 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.JoinAttempts; using FinalFactory.Rendezvous.Tests.JoinAttempts;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Server; namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class NatMediationProcessorTests public sealed class NatMediationProcessorTests
{ {
[Fact]
public void LimitedAuthenticatedUdpTrafficIsSilentlyDroppedWithoutAnIntroduction()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
AbuseProtectionOptions options = new()
{
UdpCapabilityDatagramsPerWindow = 1,
UdpResourceDatagramsPerWindow = 10,
};
AbuseProtectionService protection = new(Options.Create(options));
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service,
protection);
CaptureIntroductionSink sink = new();
string token = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
registration.HostPresenceHandle,
registration.HostPresenceCapability);
Assert.Equal(
NatMediationResult.HostPresenceAccepted,
processor.ProcessRequest(
Endpoint("192.168.1.50", 40_000),
Endpoint("203.0.113.77", 51_234),
token,
sink));
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.50", 40_000),
Endpoint("203.0.113.77", 51_234),
token,
sink));
Assert.Empty(sink.Plans);
}
[Fact] [Fact]
public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket() public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket()
{ {
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.JoinAttempts; using FinalFactory.Rendezvous.Tests.JoinAttempts;
using LiteNetLib; using LiteNetLib;
@@ -327,10 +328,12 @@ public sealed class UdpMediatorServiceTests
{ {
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new(); using JoinAttemptFixture fixture = new();
AbuseProtectionService protection = new(Options.Create(new AbuseProtectionOptions()));
NatMediationProcessor processor = new( NatMediationProcessor processor = new(
fixture.Sessions.Store, fixture.Sessions.Store,
fixture.Sessions.Capabilities, fixture.Sessions.Capabilities,
fixture.Service); fixture.Service,
protection);
using UdpMediatorService service = new( using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions Options.Create(new UdpMediatorOptions
{ {
@@ -358,6 +361,7 @@ public sealed class UdpMediatorServiceTests
using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150)); using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150));
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await sender.ReceiveAsync(noResponse.Token)); await sender.ReceiveAsync(noResponse.Token));
Assert.InRange(protection.TrackedKeyCount, 3, 5);
} }
finally finally
{ {
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
@@ -44,6 +45,8 @@ public sealed class SessionHttpEndpointTests
options.ThrowOnBadRequest = true); options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>(); builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddOptions<AbuseProtectionOptions>();
builder.Services.AddSingleton<AbuseProtectionService>();
builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization); builder.Services.AddSingleton(provisioning.PublisherAuthorization);
@@ -57,6 +60,7 @@ public sealed class SessionHttpEndpointTests
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
await using WebApplication app = builder.Build(); await using WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
await app.StartAsync(); await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>(); IServer server = app.Services.GetRequiredService<IServer>();
@@ -5,6 +5,57 @@ namespace FinalFactory.Rendezvous.Tests.State;
public sealed class InMemoryEphemeralRendezvousStoreTests public sealed class InMemoryEphemeralRendezvousStoreTests
{ {
[Fact]
public void DeterministicHostileStateTransitionsStayTypedAndCapacityBounded()
{
const int seed = 0x15_57A7E;
Random random = new(seed);
EphemeralStateFixture fixture = new(new EphemeralStoreOptions
{
MaxJoinAttempts = 32,
});
StoredListing listing = fixture.CreateVisibleListing(out _);
for (int iteration = 0; iteration < 1_000; iteration++)
{
CreateJoinAttemptCommand command = fixture.AttemptCommand(listing);
command = random.Next(4) switch
{
0 => command with
{
Scope = new(new GameId("other-game"), new EnvironmentId("test")),
},
1 => command with { ProtocolVersion = command.ProtocolVersion + 1 },
_ => command,
};
StoreResult<StoredJoinAttempt> created = fixture.Store.CreateJoinAttempt(command);
Assert.True(Enum.IsDefined(created.Code));
if (!created.Succeeded || created.Value is null)
{
continue;
}
SecretFingerprint supplied = random.Next(3) == 0
? EphemeralStateFixture.Fingerprint($"wrong-{iteration}")
: created.Value.ClientCapabilityFingerprint;
StoreResult<StoredJoinAttempt> bound = fixture.Store.BindAttemptEndpoint(new(
created.Value.MediationHandle,
AttemptPeerRole.Client,
supplied,
EphemeralStateFixture.OtherPublicEndpoint(20_000 + iteration),
null));
Assert.True(Enum.IsDefined(bound.Code));
}
StoreResult<IReadOnlyList<StoredJoinAttempt>> attempts =
fixture.Store.BrowseHostJoinAttempts(new(
listing.Definition.ListingId,
listing.Definition.LeaseFingerprint,
100));
Assert.True(attempts.Succeeded);
Assert.InRange(attempts.Value!.Count, 1, 32);
}
[Fact] [Fact]
public void IdempotencyRetentionMustCoverResourceLifetimes() public void IdempotencyRetentionMustCoverResourceLifetimes()
{ {