Three low-severity CounterStrikeSharp plugin hardening items grouped for one PR.
jointeam command listener calls int.Parse on unvalidated client argument
Location: apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs:152 (game-server)
What: HandleJoinTeam is registered as a client command listener (AddCommandListener("jointeam", HandleJoinTeam, HookMode.Pre) in FiveStackPlugin.cs:98) and does int.Parse(info.ArgByIndex(1)) with no validation. Any client can type the jointeam console command with a missing or non-numeric argument; ArgByIndex(1) returns an empty/garbage string and int.Parse throws FormatException.
Impact: A connected (unprivileged) player opens the console and runs jointeam with no argument or jointeam abc. int.Parse throws FormatException on the game thread inside the Pre hook, aborting the handler (so the team-enforcement HookResult.Stop is skipped) and spamming an unhandled exception each time; repeated invocation is a trivial griefing/log-flood vector and risks tearing down the callback path.
Suggested fix: Use int.TryParse on info.ArgByIndex(1) and return HookResult.Continue when parsing fails instead of throwing.
Verifier evidence
PlayerConnected.cs:145-152: public HookResult HandleJoinTeam(CCSPlayerController? player, CommandInfo info) { if (player == null) { return HookResult.Continue; } CsTeam joiningTeam = TeamUtility.TeamNumToCSTeam(int.Parse(info.ArgByIndex(1))); -- only a null-player guard precedes the unvalidated int.Parse. FiveStackPlugin.cs:98: AddCommandListener("jointeam", HandleJoinTeam, HookMode.Pre); confirms it runs on client-issued jointeam commands. No ArgCount/TryParse validation exists (grep found no ArgCount usage guarding this call).
Administrator role tag overwritten with 'organizer' due to if/else structure
Location: apps/counterstrikesharp/src/FiveStack.Events/InitConnect.cs:150 (game-server)
What: Lines 142-153 set PendingPlayers[steamId]="admin" for an Administrator in a standalone if, then a separate if (Streamer) ... else ... block runs unconditionally afterward. For an Administrator the Streamer branch is false, so the else executes and overwrites the value with "organizer".
Impact: A user with the Administrator role connects with a valid game token. They are correctly authorized to connect, but PendingPlayers ends up "organizer" instead of "admin", so PlayerConnected sets their clan tag to "[organizer]" and they are never identified as an administrator in-game.
Suggested fix: Restructure to an if/else-if chain (Administrator, else if Streamer, else organizer) so the Administrator assignment is not clobbered.
Verifier evidence
InitConnect.cs:142-153:
if (playerRole == ePlayerRoles.Administrator) (142)
PendingPlayers[steamId] = "admin"; (144)
if (playerRole == ePlayerRoles.Streamer) (146, note: separate if, not else if)
PendingPlayers[steamId] = "streamer"; (148)
else (150)
PendingPlayers[steamId] = "organizer"; (152)
For Administrator, line 146 is false so lines 150-152 overwrite the line-144 value with "organizer".
Connect-authorization HMAC token compared with non-constant-time string equality
Location: apps/swiftly/src/FiveStack.Events/InitConnect.cs:238 (game-server)
What: The ConnectClient hook verifies a client-supplied role token by computing an HMAC-SHA256 over {type}:{role}:{steamId}:{matchId} (key = match password) and comparing it to the client value with if (computedToken != password) (ordinary String inequality). String comparison short-circuits at the first differing character, making the check non-constant-time. A successful match grants elevated in-server roles (admin/organizer/streamer). The counterstrikesharp app uses the same non-constant-time compare (InitConnect.cs:120).
Impact: An attacker who can repeatedly attempt connections with their own (Steam-verified) steamId and a known matchId submits game:Administrator:<guess> tokens and measures server-side response timing to recover the base64 HMAC byte-by-byte, ultimately forging an Administrator/Organizer authorization without knowing the match password. Exploitation is difficult over a noisy network path but the side channel is real and guards a privilege boundary.
Suggested fix: Compare the decoded token bytes with CryptographicOperations.FixedTimeEquals (or hash both sides and compare) instead of !=, in both apps.
Verifier evidence
apps/swiftly/src/FiveStack.Events/InitConnect.cs:238 if (computedToken != password); apps/swiftly/src/FiveStack.Utilities/ConnectAuth.cs:16-20 computes HMACSHA256 over $"{type}:{role}:{steamId}:{matchId}" and returns Convert.ToBase64String(hash); counterstrikesharp mirror apps/counterstrikesharp/src/FiveStack.Events/InitConnect.cs:110-120 (computedToken = Convert.ToBase64String(computedHash) then if (computedToken != password)). Grep for FixedTimeEquals returns no matches in game-server source, confirming no constant-time compare exists.
Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P3). One of a batch of findings from that pass.
Three low-severity CounterStrikeSharp plugin hardening items grouped for one PR.
jointeam command listener calls int.Parse on unvalidated client argument
Location:
apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs:152(game-server)What: HandleJoinTeam is registered as a client command listener (AddCommandListener("jointeam", HandleJoinTeam, HookMode.Pre) in FiveStackPlugin.cs:98) and does int.Parse(info.ArgByIndex(1)) with no validation. Any client can type the jointeam console command with a missing or non-numeric argument; ArgByIndex(1) returns an empty/garbage string and int.Parse throws FormatException.
Impact: A connected (unprivileged) player opens the console and runs
jointeamwith no argument orjointeam abc. int.Parse throws FormatException on the game thread inside the Pre hook, aborting the handler (so the team-enforcement HookResult.Stop is skipped) and spamming an unhandled exception each time; repeated invocation is a trivial griefing/log-flood vector and risks tearing down the callback path.Suggested fix: Use int.TryParse on info.ArgByIndex(1) and return HookResult.Continue when parsing fails instead of throwing.
Verifier evidence
PlayerConnected.cs:145-152:
public HookResult HandleJoinTeam(CCSPlayerController? player, CommandInfo info) { if (player == null) { return HookResult.Continue; } CsTeam joiningTeam = TeamUtility.TeamNumToCSTeam(int.Parse(info.ArgByIndex(1)));-- only a null-player guard precedes the unvalidated int.Parse. FiveStackPlugin.cs:98:AddCommandListener("jointeam", HandleJoinTeam, HookMode.Pre);confirms it runs on client-issued jointeam commands. No ArgCount/TryParse validation exists (grep found no ArgCount usage guarding this call).Administrator role tag overwritten with 'organizer' due to if/else structure
Location:
apps/counterstrikesharp/src/FiveStack.Events/InitConnect.cs:150(game-server)What: Lines 142-153 set PendingPlayers[steamId]="admin" for an Administrator in a standalone if, then a separate
if (Streamer) ... else ...block runs unconditionally afterward. For an Administrator the Streamer branch is false, so the else executes and overwrites the value with "organizer".Impact: A user with the Administrator role connects with a valid game token. They are correctly authorized to connect, but PendingPlayers ends up "organizer" instead of "admin", so PlayerConnected sets their clan tag to "[organizer]" and they are never identified as an administrator in-game.
Suggested fix: Restructure to an if/else-if chain (Administrator, else if Streamer, else organizer) so the Administrator assignment is not clobbered.
Verifier evidence
InitConnect.cs:142-153:
if (playerRole == ePlayerRoles.Administrator)(142)PendingPlayers[steamId] = "admin";(144)if (playerRole == ePlayerRoles.Streamer)(146, note: separateif, notelse if)PendingPlayers[steamId] = "streamer";(148)else(150)PendingPlayers[steamId] = "organizer";(152)For Administrator, line 146 is false so lines 150-152 overwrite the line-144 value with "organizer".
Connect-authorization HMAC token compared with non-constant-time string equality
Location:
apps/swiftly/src/FiveStack.Events/InitConnect.cs:238(game-server)What: The ConnectClient hook verifies a client-supplied role token by computing an HMAC-SHA256 over
{type}:{role}:{steamId}:{matchId}(key = match password) and comparing it to the client value withif (computedToken != password)(ordinary String inequality). String comparison short-circuits at the first differing character, making the check non-constant-time. A successful match grants elevated in-server roles (admin/organizer/streamer). The counterstrikesharp app uses the same non-constant-time compare (InitConnect.cs:120).Impact: An attacker who can repeatedly attempt connections with their own (Steam-verified) steamId and a known matchId submits
game:Administrator:<guess>tokens and measures server-side response timing to recover the base64 HMAC byte-by-byte, ultimately forging an Administrator/Organizer authorization without knowing the match password. Exploitation is difficult over a noisy network path but the side channel is real and guards a privilege boundary.Suggested fix: Compare the decoded token bytes with CryptographicOperations.FixedTimeEquals (or hash both sides and compare) instead of
!=, in both apps.Verifier evidence
apps/swiftly/src/FiveStack.Events/InitConnect.cs:238
if (computedToken != password); apps/swiftly/src/FiveStack.Utilities/ConnectAuth.cs:16-20 computesHMACSHA256over$"{type}:{role}:{steamId}:{matchId}"and returnsConvert.ToBase64String(hash); counterstrikesharp mirror apps/counterstrikesharp/src/FiveStack.Events/InitConnect.cs:110-120 (computedToken = Convert.ToBase64String(computedHash)thenif (computedToken != password)). Grep forFixedTimeEqualsreturns no matches in game-server source, confirming no constant-time compare exists.Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P3). One of a batch of findings from that pass.