fix: security + robustness audit (auth bypass, server-list DoS, JSON injection, input validation) - #250
fix: security + robustness audit (auth bypass, server-list DoS, JSON injection, input validation)#250itpick wants to merge 18 commits into
Conversation
Six ownership checks called Unauthorized() and discarded the result instead of returning it, so any authenticated session could update, delete, heartbeat, start/stop, or edit the player lists of game servers owned by other clients.
ControllerBase.Forbid(string...) interprets its arguments as authentication scheme names, not a message. Executing ForbidResult with a scheme like "Cannot modify reserved clients" throws (no such scheme is registered) and surfaces as a 500 instead of a 403.
ChangePassword never updated logLevel before returning Ok, so every successful admin password change was logged as a Warning claiming the admin 'was not authorized to change' the password (same pattern as SetAccountFlags, which does set logLevel on success).
ToJObject sized a fixed array and only filled slots for keys ending in _b/_i/_s, casting the value based on the key suffix. An attribute with any other key suffix left a default entry whose null key made the JsonObject constructor throw, and a value whose type mismatched its suffix threw InvalidCastException. Either way a single server registering such an attribute broke the server list for all clients. Emit by the actual stored value type instead and skip anything else.
The account lookup uses SingleOrDefaultAsync but the result was
dereferenced unconditionally. A rating row whose account was deleted
made GET ratings/ranking/{accountId} throw a NullReferenceException.
Fall back to the same Unknown user / default flag placeholders that
GetRankingsAsync already uses.
GET ratings/rankings passed caller-controlled skip/limit straight to the database: a huge or zero/negative limit dumped the entire ranking collection (limit 0 means unlimited in MongoDB) and a negative skip threw in the driver. Clamp to 0 <= skip and 1 <= limit <= 100; the website pages with limit 10 so this changes nothing for normal use.
RegisterAccount created a new undisposed HttpClient on every registration attempt (socket/handle leak, port exhaustion under load) and interpolated the caller-supplied recaptcha token into the query string unescaped. Use a shared static client and Uri.EscapeDataString.
MatchmakingWaitTimeEstimateService is registered as a singleton but mutated its Dictionary/List state from concurrent requests without any locking; simultaneous report/estimate calls could corrupt the dictionary or throw during enumeration. Guard both entry points with a lock (Clean is only called under it).
The input formatter unconditionally stripped the last character of the request body (to drop the trailing NUL the game appends). An empty body threw ArgumentOutOfRangeException and a body without a trailing NUL had its closing brace chopped; malformed json surfaced as a 500. TrimEnd the NUL instead and translate empty/invalid bodies into a 400 via InputFormatterResult.Failure.
The timer callback ran the cleanup pass inside Task.Run with no error handling; any exception (transient DB outage, bad timezone id in settings, etc.) faulted the task unobserved so cleanup failures were completely invisible. Extract the body into DoWorkAsync and log failures.
The fake stats.json fallback interpolated the account username into a raw json string. Username validation only limits length and a word blocklist, so names containing a double quote or backslash produced malformed json (and allowed injecting arbitrary fields). Build the document with JObject instead.
The pipeline registered UseAuthorization before UseAuthentication, the reverse of the documented required order. Behavior is currently unaffected because all endpoints authenticate via explicit scheme attributes (the policy evaluator invokes handlers itself), but the correct order prevents subtle breakage if a default scheme or global policy is ever added.
Replace Where(x => x == player).Any() enumerator allocations with Contains, and drop the redundant pre-check before Remove (Remove is already a no-op when the element is absent). Both use the same IEquatable<EpicID> comparison as before.
Only upper bounds were validated, so a crafted request could submit a negative XPAmount and lower an account's XP/level (a server session may grant XP to any account). Clamp negatives to zero and log them.
Earlier commits on this branch accidentally normalized this file's mixed CRLF/LF line endings while editing; restore the original bytes so the branch diff only contains the intended changes.
|
Please add tests for your changes |
There was a problem hiding this comment.
Pull request overview
This PR applies a security + robustness audit across the UT4 master server API surface, fixing several issues that could enable unauthorized game-server mutation, denial-of-service conditions in server listing, and unsafe JSON construction, while also tightening input validation and concurrency behavior.
Changes:
- Fix authorization enforcement gaps in
MatchmakingController(previously computedUnauthorized()but continued execution). - Harden several endpoints/services against malformed or abusive inputs (paging bounds, negative XP, safer request-body parsing).
- Improve robustness and resource usage (shared
HttpClient, background cleanup exception handling, locking for singleton state, safer JSON emission/serialization).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| UT4MasterServer/Program.cs | Corrects middleware ordering (UseAuthentication() before UseAuthorization()). |
| UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs | Makes request-body parsing resilient to empty bodies / missing trailing NUL and invalid JSON. |
| UT4MasterServer/Controllers/UT/RatingsController.cs | Clamps anonymous paging parameters to prevent unbounded queries. |
| UT4MasterServer/Controllers/UT/ProfileController.cs | Prevents negative XP from reducing account XP/level. |
| UT4MasterServer/Controllers/UT/MatchmakingController.cs | Fixes auth bypass by returning Unauthorized(); minor player-list update perf cleanup. |
| UT4MasterServer/Controllers/Epic/CloudStorageController.cs | Prevents JSON injection when generating stats.json by building JSON via JObject. |
| UT4MasterServer/Controllers/Epic/AccountController.cs | Reuses a shared HttpClient and escapes recaptcha query params. |
| UT4MasterServer/Controllers/AdminPanelController.cs | Fixes incorrect Forbid(message) usage and corrects password-change log level on success. |
| UT4MasterServer.Services/Singleton/MatchmakingWaitTimeEstimateService.cs | Adds locking to prevent concurrent mutation of singleton-held collections. |
| UT4MasterServer.Services/Scoped/RatingsService.cs | Avoids null-deref when account lookup returns null; returns placeholder values. |
| UT4MasterServer.Services/Hosted/ApplicationBackgroundService.cs | Ensures background cleanup exceptions are caught and logged instead of silently killing cleanup. |
| UT4MasterServer.Models/GameServerAttributes.cs | Makes server-attribute JSON emission robust to unexpected keys/types to avoid crashing server-list serialization. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- StatisticBaseInputFormatter: JsonSerializer.Deserialize returns null when the request body is the json literal "null"; returning Success(null) surfaced later as a null model in the action. Treat it as Failure like the other invalid-body cases. - AccountController.RegisterAccount: dispose the recaptcha HttpResponseMessage with a using declaration so the underlying connection is returned to the pool.
- StatisticBaseInputFormatter: move body parsing (NUL trim, empty/invalid/ null-literal rejection) into an internal static TryParse so it can be unit tested without constructing an InputFormatterContext. No behavior change. - RatingsController: move the GetRankings skip/limit clamping into an internal static ClampPaging helper. No behavior change. - Expose internals to the XUnit.Tests project.
…d rankings paging - GameServerAttributeTest: replace the commented-out test (it targeted a removed Eq/Lt/Lte comparison API) with tests for ToJObject: emits by actual stored type, tolerates keys without a known type suffix and values that mismatch their key suffix (both used to crash serialization of the entire server list), and drops attributes set to null. - StatisticBaseInputFormatterTest: TryParse rejects empty/whitespace/NUL bodies, the json literal null and malformed json; parses valid bodies with and without the game's trailing NUL terminator. - RatingsControllerTest: ClampPaging boundary cases for the anonymous rankings endpoint.
|
Addressed both the review comments and added tests. Copilot review fixes (commit
Tests (
To enable those two I extracted the pure logic into small Verified locally: solution builds clean and all the new tests pass. (The pre-existing The remaining fixes are integration-level (Matchmaking ownership |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (1)
UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs:24
ReadRequestBodyAsyncreturnsInputFormatterResult.Failure()on parse failure but does not add aModelStateerror. In ASP.NET Core, formatters are expected to record a model-state error themselves; otherwise the request may proceed with an unbound/nullbody (risking a 500 inStatsController.CreateAccountStatistics) instead of producing a 400 as intended.
if (!TryParse(rawValue, out StatisticBase? newObject))
{
return InputFormatterResult.Failure();
}
Audit pass over the master server turning up several correctness/security bugs, each fixed as its own commit.
Security / correctness
MatchmakingControllerupdate/delete/heartbeat/updatePlayers/removePlayer/changeStarted computedUnauthorized()but discarded the result (noreturn), so any authenticated session could modify or delete another client's game server and edit its player list.GameServerAttributes.ToJObjectleft null keys / cast by key-suffix, so one server registering an attribute whose key didn't end in_b/_i/_s(or mismatched its stored type) 500'd the whole server list. Now emits by actual stored type and skips others.stats.jsoninterpolated the username into raw JSON (usernames allow"/\). Built viaJObjectnow.RatingsController.GetRankings([AllowAnonymous]) had nolimitclamp (limit=0= unlimited in Mongo) and passed negativeskip. Clamped toskip>=0,1<=limit<=100.ProfileController.GrantXPonly bounded the upper side; a negative amount lowered any account's XP/level. Clamped to>=0.Forbid(message)in AdminPanelController treated the message as an auth scheme name (throws 500 instead of 403) →StatusCode(403, ...).Robustness / resources
AccountController.RegisterAccountcreated a new undisposedHttpClientper registration (socket exhaustion) and interpolated the caller's recaptcha token unescaped → sharedstatic readonly HttpClient+Uri.EscapeDataString.MatchmakingWaitTimeEstimateService(singleton) mutated aDictionary/Listfrom concurrent requests with no locking →lock.StatisticBaseInputFormatterblindly chopped the last body char (empty body → 500, missing trailing NUL → truncated JSON) →TrimEnd('\0')+Failure()(400) on empty/invalid.ApplicationBackgroundServicecleanup ran inTask.Runwith no catch (any failure silently killed cleanup) → catch/log.RatingsService.GetSelectedRankingAsyncderef'd a possibly-nullSingleOrDefaultAsyncaccount → placeholder fallback (matchesGetRankingsAsync).Program.cs:UseAuthorization()ran beforeUseAuthentication()(benign today, ordered correctly to avoid future breakage).Perf
UpdateGameServerPlayers:Where(...).Any()→List.Contains, dropped redundant pre-Removeexistence checks.Reviewed against net6.0 semantics; no functional change to happy paths. Independent of #249.