feat(server): harden hostile input and overload behavior (#15)
quality-gate / quality (push) Failing after 1m5s

This commit is contained in:
KyuubiYoru
2026-07-16 12:37:38 +02:00
parent 2ff7cd6d9d
commit 88ef946af5
22 changed files with 2159 additions and 93 deletions
@@ -1,5 +1,6 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.JoinAttempts;
@@ -20,6 +21,7 @@ internal static class ContractEndpoints
.Accepts<RegisterSessionRequest>("application/json")
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status409Conflict)
@@ -31,45 +33,54 @@ internal static class ContractEndpoints
.Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseHostJoinAttempts");
@@ -80,6 +91,7 @@ internal static class ContractEndpoints
.Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
@@ -90,14 +102,17 @@ internal static class ContractEndpoints
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CancelJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("ReportConnectionOutcome");
@@ -109,6 +124,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
@@ -122,13 +138,29 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext);
}
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
principal!,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
: Error(result.Error);
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"RegisterSession",
Tenant(publisher.GameId, publisher.EnvironmentId),
publisher.Subject,
null,
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
principal!,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
: Error(result.Error);
}
}
private static IResult RenewLease(
@@ -137,6 +169,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
@@ -150,14 +183,30 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext);
}
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"RenewSessionLease",
Tenant(publisher.GameId, publisher.EnvironmentId),
publisher.Subject,
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
}
private static IResult UpdateSession(
@@ -166,6 +215,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
@@ -179,12 +229,28 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext);
}
SessionServiceResult<bool> result = sessions.Update(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"UpdateSession",
Tenant(publisher.GameId, publisher.EnvironmentId),
publisher.Subject,
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<bool> result = sessions.Update(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
}
private static IResult DeleteSession(
@@ -193,6 +259,7 @@ internal static class ContractEndpoints
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] AbuseProtectionService abuseProtection,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
@@ -206,12 +273,28 @@ internal static class ContractEndpoints
return AuthenticationRequired(httpContext);
}
SessionServiceResult<bool> result = sessions.Delete(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
IPublisherPrincipal publisher = (IPublisherPrincipal)principal!;
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"DeleteSession",
Tenant(publisher.GameId, publisher.EnvironmentId),
publisher.Subject,
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
SessionServiceResult<bool> result = sessions.Delete(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
}
private static IResult BrowseSessions(
@@ -224,6 +307,8 @@ internal static class ContractEndpoints
[FromQuery] bool? excludeFull,
[FromQuery] string? cursor,
[FromServices] SessionBrowserService browser,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!GameId.TryParse(gameId, out GameId parsedGameId)
@@ -233,20 +318,35 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.InvalidRequest);
}
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"BrowseSessions",
Tenant(parsedGameId, parsedEnvironmentId),
null,
null,
out AbuseProtectionService.AbuseLease? abuseLease))
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
ExcludeFull = excludeFull ?? false,
Cursor = cursor,
}, cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
return RateLimited(httpContext);
}
using (abuseLease)
{
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
ExcludeFull = excludeFull ?? false,
Cursor = cursor,
}, cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
}
private static IResult GetSession(
@@ -256,6 +356,8 @@ internal static class ContractEndpoints
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromServices] SessionBrowserService browser,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
@@ -269,15 +371,30 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.InvalidRequest);
}
BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId,
parsedGameId,
parsedEnvironmentId,
protocolVersion,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"GetSession",
Tenant(parsedGameId, parsedEnvironmentId),
null,
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId,
parsedGameId,
parsedEnvironmentId,
protocolVersion,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
}
private static IResult BrowseHostJoinAttempts(
@@ -287,23 +404,41 @@ internal static class ContractEndpoints
[FromQuery] int? pageSize,
[FromQuery] string? cursor,
[FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
listingId,
contractVersion,
leaseToken,
pageSize ?? ContractLimits.BrowserPageMaxItems,
cursor,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"BrowseHostJoinAttempts",
null,
AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty),
listingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
listingId,
contractVersion,
leaseToken,
pageSize ?? ContractLimits.BrowserPageMaxItems,
cursor,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
}
private static IResult CreateJoinAttempt(
[FromBody] CreateJoinAttemptRequest request,
[FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
@@ -313,26 +448,58 @@ internal static class ContractEndpoints
}
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
clientSubject,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
: Error(result.Error);
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"CreateJoinAttempt",
Tenant(request.GameId, request.EnvironmentId),
clientSubject,
request.ListingId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
clientSubject,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
: Error(result.Error);
}
}
private static IResult CancelJoinAttempt(
JoinAttemptId attemptId,
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromServices] JoinAttemptService attempts,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
JoinAttemptServiceResult<bool> result = attempts.Cancel(
attemptId,
clientPunchCapability,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"CancelJoinAttempt",
null,
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
attemptId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
JoinAttemptServiceResult<bool> result = attempts.Cancel(
attemptId,
clientPunchCapability,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
}
private static IResult ReportConnectionOutcome(
@@ -340,16 +507,33 @@ internal static class ContractEndpoints
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromBody] ReportConnectionOutcomeRequest request,
[FromServices] ConnectionOutcomeService outcomes,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
ConnectionOutcomeServiceResult result = outcomes.Report(
attemptId,
clientPunchCapability,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"ReportConnectionOutcome",
null,
AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty),
attemptId.ToString(),
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
ConnectionOutcomeServiceResult result = outcomes.Report(
attemptId,
clientPunchCapability,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
}
private static bool TryAuthenticatePublisher(
@@ -377,11 +561,41 @@ internal static class ContractEndpoints
return true;
}
private static IResult Error(RendezvousErrorCode code) => Results.Json(
private static bool TryAcquireIdentity(
AbuseProtectionService abuseProtection,
HttpContext httpContext,
string operation,
string? tenant,
string? principal,
string? resource,
out AbuseProtectionService.AbuseLease? lease)
{
if (abuseProtection.TryAcquireHttpIdentity(
operation,
httpContext.Connection.RemoteIpAddress,
tenant,
principal,
resource,
out lease,
out int retryAfterSeconds))
{
return true;
}
httpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString(
System.Globalization.CultureInfo.InvariantCulture);
return false;
}
private static string Tenant(GameId gameId, EnvironmentId environmentId) =>
$"{gameId.Value}/{environmentId.Value}";
private static IResult Error(RendezvousErrorCode code, int? retryAfterSeconds = null) => Results.Json(
new ApiError
{
Code = code,
Message = ErrorMessage(code),
RetryAfterSeconds = retryAfterSeconds,
},
ContractJson.Options,
statusCode: ErrorStatus(code));
@@ -392,6 +606,18 @@ internal static class ContractEndpoints
return Error(RendezvousErrorCode.AuthenticationRequired);
}
private static IResult RateLimited(HttpContext context)
{
int? retryAfterSeconds = int.TryParse(
context.Response.Headers.RetryAfter,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parsed)
? Math.Clamp(parsed, 1, 60)
: null;
return Error(RendezvousErrorCode.RateLimited, retryAfterSeconds);
}
private static int ErrorStatus(RendezvousErrorCode code) => code switch
{
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
@@ -417,6 +643,7 @@ internal static class ContractEndpoints
RendezvousErrorCode.Expired => "The session lease has expired.",
RendezvousErrorCode.StaleHost => "The session has no fresh host presence.",
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
RendezvousErrorCode.RateLimited => "The request rate limit was exceeded.",
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",