Skip to content

fix: security + robustness audit (auth bypass, server-list DoS, JSON injection, input validation) - #250

Open
itpick wants to merge 18 commits into
timiimit:masterfrom
ut4-hub:audit/master-fixes
Open

fix: security + robustness audit (auth bypass, server-list DoS, JSON injection, input validation)#250
itpick wants to merge 18 commits into
timiimit:masterfrom
ut4-hub:audit/master-fixes

Conversation

@itpick

@itpick itpick commented Jul 30, 2026

Copy link
Copy Markdown

Audit pass over the master server turning up several correctness/security bugs, each fixed as its own commit.

Security / correctness

  • Game-server ownership auth bypassMatchmakingController update/delete/heartbeat/updatePlayers/removePlayer/changeStarted computed Unauthorized() but discarded the result (no return), so any authenticated session could modify or delete another client's game server and edit its player list.
  • Server-list DoSGameServerAttributes.ToJObject left 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.
  • JSON injection — CloudStorage's generated stats.json interpolated the username into raw JSON (usernames allow "/\). Built via JObject now.
  • Unbounded anonymous queryRatingsController.GetRankings ([AllowAnonymous]) had no limit clamp (limit=0 = unlimited in Mongo) and passed negative skip. Clamped to skip>=0, 1<=limit<=100.
  • Negative XPProfileController.GrantXP only 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.RegisterAccount created a new undisposed HttpClient per registration (socket exhaustion) and interpolated the caller's recaptcha token unescaped → shared static readonly HttpClient + Uri.EscapeDataString.
  • MatchmakingWaitTimeEstimateService (singleton) mutated a Dictionary/List from concurrent requests with no locking → lock.
  • StatisticBaseInputFormatter blindly chopped the last body char (empty body → 500, missing trailing NUL → truncated JSON) → TrimEnd('\0') + Failure() (400) on empty/invalid.
  • ApplicationBackgroundService cleanup ran in Task.Run with no catch (any failure silently killed cleanup) → catch/log.
  • RatingsService.GetSelectedRankingAsync deref'd a possibly-null SingleOrDefaultAsync account → placeholder fallback (matches GetRankingsAsync).
  • Successful admin password change was logged as a Warning "not authorized" (log level never updated).
  • Program.cs: UseAuthorization() ran before UseAuthentication() (benign today, ordered correctly to avoid future breakage).

Perf

  • UpdateGameServerPlayers: Where(...).Any()List.Contains, dropped redundant pre-Remove existence checks.

Reviewed against net6.0 semantics; no functional change to happy paths. Independent of #249.

itpick added 15 commits July 29, 2026 22:34
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.
@Saibamen
Saibamen requested a review from Copilot July 30, 2026 09:09
@Saibamen

Copy link
Copy Markdown
Collaborator

Please add tests for your changes

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 computed Unauthorized() 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.

Comment thread UT4MasterServer/Formatters/StatisticBaseInputFormatter.cs Outdated
Comment thread UT4MasterServer/Controllers/Epic/AccountController.cs Outdated
itpick added 3 commits July 30, 2026 07:43
- 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.
@itpick

itpick commented Jul 30, 2026

Copy link
Copy Markdown
Author

Addressed both the review comments and added tests.

Copilot review fixes (commit 3139c70):

  • StatisticBaseInputFormatter — a body of the JSON literal null now returns InputFormatterResult.Failure() instead of Success(null) (was surfacing as a later null-ref/500).
  • AccountController.RegisterAccount — the reCaptcha HttpResponseMessage is now a using declaration so it's disposed and returns the connection to the pool.

Tests (767789e extract testable helpers, fd2a6cd tests) — I focused on the changes that are unit-testable without a DI/DB/auth harness (the project has no mocking lib):

  • GameServerAttributeTest — locks in the server-list-DoS fix: ToJObject() emits by actual stored type (bool/int/string), doesn't throw on a key without a _b/_i/_s suffix, and handles suffix/type mismatches — the cases that used to 500 the whole server list.
  • StatisticBaseInputFormatterTest — the body parser (empty / trailing-NUL / null literal / malformed / array / string → failure; valid bodies → parsed), via a small extracted TryParse helper.
  • RatingsControllerTestClampPaging boundaries (skip≥0, 1≤limit≤100) for the anonymous rankings endpoint.

To enable those two I extracted the pure logic into small internal static helpers (behavior unchanged) and added InternalsVisibleTo("XUnit.Tests").

Verified locally: solution builds clean and all the new tests pass. (The pre-existing EloTest.TestTeamEloBalancing statistical failures are unrelated — that test and the Elo code aren't touched by this PR.)

The remaining fixes are integration-level (Matchmaking ownership returns, ProfileController.GrantXP clamp, AdminPanel/CloudStorage, the hosted/singleton services) — they need mocked services/DB/auth, so I left them uncovered rather than add a mocking dependency in this PR. Happy to follow up if you'd prefer a test harness for those.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • ReadRequestBodyAsync returns InputFormatterResult.Failure() on parse failure but does not add a ModelState error. In ASP.NET Core, formatters are expected to record a model-state error themselves; otherwise the request may proceed with an unbound/null body (risking a 500 in StatsController.CreateAccountStatistics) instead of producing a 400 as intended.
		if (!TryParse(rawValue, out StatisticBase? newObject))
		{
			return InputFormatterResult.Failure();
		}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants