Repository navigation
fix(game): close DataPack ownership leaks and stale client-index reuse in the ban flow - #40
Conversation
Closes the 4 remaining DataPack leaks identified in sbpp#1570 (comment) after the #21 port: the outer PlayerDataPack always owns a nested reasonPack allocated in PrepareBan/CreateBan, but four sites only freed (or skipped freeing) the outer pack, leaking the nested one on every affected code path. - VerifyInsert success path: the current transaction's ReasonPack was only deleted when a stale PlayerDataPack[admin] happened to exist (true for menu-selected reasons, false for command-issued bans with a reason), so command bans leaked it every time. Delete it unconditionally, and clean up the stale PlayerDataPack[admin] with CleanupBanDataPack() instead of a shallow delete. - VerifyInsert disconnected-client early return: leaked both dataPack and its nested reasonPack since neither was freed before the return. - OnMapEnd: replaced the shallow `delete PlayerDataPack[i]` (with a stale "need to close reason pack" comment) with CleanupBanDataPack(). - ReasonSelected's MenuCancel_Disconnected branch: same shallow-delete bug, now matches the already-correct cleanup in HackingSelected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Part of a broader DataPack-lifecycle audit prompted by sbpp#1570; the other scripting/*.sp files (checker, sleuth, report, admcfg) were audited and found clean. When an unsilence (TYPE_UNSILENCE) also needs to clear a leftover temp mute/gag, Query_UnBlockSelect reuses its own dataPack and hands it to TempUnBlock(), which unconditionally deletes it after reading the fields it needs. Execution then fell through to this function's own unconditional `if (dataPack != null) delete dataPack;` at the end, freeing the same handle a second time — SourcePawn doesn't null out other copies of a Handle when it's deleted, so the leftover local still compared non-null. This fires on every unsilence where the target still has an active temp mute or gag after the DB-backed one is cleared, and either throws an invalid-handle error or frees an unrelated handle if the slot was recycled in between. Return immediately after handing the pack to TempUnBlock(), matching the same ownership-transfer pattern already used a few lines above for the query-failed/no-results early return. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Broader sweep across every scripting/*.sp plugin for handle leaks (DataPack, ArrayList, StringMap, Timer, Menu, KeyValues, SMCParser), prompted by continuing the sbpp#1570 cleanup. checker, sleuth, report and admcfg came back clean. Found and fixed: sbpp_comms.sp: - CreateMuteExpireTimer/CreateGagExpireTimer overwrote g_hMuteExpireTimer[target]/g_hGagExpireTimer[target] with a new Timer Handle without closing a still-pending previous one first (e.g. Query_VerifyBlock's connect-time loop can call PerformMute/ PerformGag more than once for the same client if the DB has more than one active row for them). Now calls the existing CloseMuteExpireTimer/CloseGagExpireTimer helpers first. sbpp_main.sp: - OnClientDisconnect deleted the pending PlayerRecheck[client] retry timer but never reset the slot to INVALID_HANDLE, so a later disconnect of a different player reusing that client index would delete the same stale handle value again. - VerifyInsert's DB-insert-failure branch and CreateBan's empty-reason branch both overwrote/nulled PlayerDataPack[admin] without freeing whatever pack (and its nested reasonPack) might already be sitting there from an earlier, still-unresolved no-reason ban by the same admin. Both now call CleanupBanDataPack() first, matching the pattern VerifyInsert's success path already used. - ParseBackupConfig_Overrides leaked its KeyValues handle on both early-return paths (missing/empty overrides_backup.cfg) — hit every time the DB is unreachable at startup or the overrides query fails. - SelectUnbanCallback leaked dataPack in the (defensive/edge-case) branch where RowCount > 0 but FetchRow() still returns false. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Companion to the DataPack/Timer/KeyValues handle-leak sweep already on this branch: upstream sbpp#1571 ("close DataPack ownership leaks") also fixed a second, distinct bug class in the same functions that this branch's earlier commits didn't cover - stale client-index reuse across an async DB round trip. Every ban command (sm_ban, sm_banip, sm_addban, sm_unban) and the target-list/time/reason menu flow packs the issuing admin's (and, for the menu flow, the target's) raw client index into a DataPack or a per-slot global array, then reads it back after an async SQL query or after the admin steps through several menu screens. SourceMod reuses a freed client slot immediately, so if the original admin or target disconnects in that window and a different player connects into the same slot, the stale index now points at an unrelated player. The existing IsClientInGame()/IsClientConnected() guards can't tell "still the same player" from "someone else now" - only a userid survives a disconnect/reconnect as a stable identity. sbpp_comms.sp already carries this pattern (Query_UnBlockSelect reads adminUserID/targetUserID and resolves via GetClientOfUserId()); this port brings sbpp_main.sp's ban flow in line with it: - New g_BanTargetUserId[] parallels g_BanTarget[]/g_BanTime[] so the target-list -> time -> reason menu chain (ReasonSelected, HackingSelected, ChatHook's own-reason path) re-validates the target's userid in PrepareBan() before banning, instead of trusting a live-looking but possibly-reused client index. - CommandBanIp/CommandUnban/CommandAddBan now pack the admin's userid instead of the raw index; SelectBanIpCallback, InsertBanIpCallback, SelectUnbanCallback, InsertUnbanCallback, SelectAddbanCallback and InsertAddbanCallback all resolve it back via GetClientOfUserId() before using it for ShowActivity2()/LogAction()/PrintToChat() - the existing "admin && IsClientInGame(admin)" guards stay correct as-is since GetClientOfUserId() returns 0 for a slot that moved on. - VerifyInsert (both the success path and the primary-DB-failure path that falls back to the SQLite queue) and UTIL_InsertTempBan now re-resolve admin from the adminUserId field the pack already carried but discarded, and UTIL_InsertTempBan additionally re-resolves the target from targetUserId before deciding whether to kick them. - Native_SBBanPlayer's PrepareBan() call passes the target's current userid, which is a no-op validation there since the call is synchronous - kept for signature consistency and because the native is public API third-party plugins call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bpp#1571/sbpp#1572 Two bugs surfaced while comparing this fork's Synergy/queue-related plugin fixes against upstream sbpp#1572 ("use native auth ids for engine bans") — upstream's diff touches the same functions and happens to carry the fix for the first one as a side effect. 1. ProcessQueueCallback re-INSERTs a matured queue row into the main `%!s_bans` table, but was building and executing that query against `db` — the local SQLite queue connection this callback fires on (bound via SQLiteDB.Query(ProcessQueueCallback, ...) in ProcessQueue()) — instead of `DB`, the main panel database the `%!s_bans` table actually lives on. SQLite has no such table, so the INSERT would fail every time; AddedFromSQLiteCallback's `results == null` branch just re-arms the temporary in-game hold and leaves the row in the queue, so the ban would keep getting re-enforced locally but NEVER actually land in the permanent database once it came back up after an outage — silently defeating the entire point of the offline queue. Switched both the `.Format()` call (MySQL vs SQLite escaping rules differ) and the final `.Query()` to `DB`. Paired with a `DB == INVALID_HANDLE` guard at the top of the callback (matching upstream) so this doesn't trade "silently fails forever" for "throws a native error" on a cycle where the main DB is still down. 2. AddedFromSQLiteCallback's success path calls `RemoveBan(auth, BANFLAG_AUTHID)` with the raw Steam2 identity to clear the temporary in-game hold once the real ban lands in the main database. But the temp hold was created by SBPP_BanIdentity(), which converts to SteamID3 whenever it can (some engines, e.g. Synergy, reject the STEAM_ format for the "banid" console command, so SBPP_BanIdentity works around it) — a Steam2-only RemoveBan() call silently misses a SteamID3-keyed entry, leaving a stale temporary ban sitting in SourceMod's cache until it expires on its own. Try the SteamID3 form too; removing an identity with no matching ban is a harmless no-op. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Added a third commit while investigating upstream sbpp#1572 ("use native auth ids for engine bans") for a separate audit — turned up two bugs in the SQLite ban-queue reprocessing path that are worth fixing alongside the DataPack/userid work already here:
Neither bug is new in this PR — both predate it — but they surfaced from reading the same functions upstream's sbpp#1572 touches, so bundling them here made more sense than a separate PR against the same file. |
UTIL_InsertTempBan now resolves the target via GetClientOfUserId(), which returns 0 once the target is gone -- the normal case on the primary-DB failure path, since PrepareBan() kicks right after CreateBan(). IsClientInGame(0) raises a native error, aborting before SBPP_BanIdentity() and the SQLite queue INSERT, so the ban was lost. Guard client > 0. Also stop Native_SBBanPlayer from calling GetClientUserId() on an invalid or disconnected target (throws in the caller plugin); pass 0 so PrepareBan()'s own guard keeps the previous silent no-op.
ProcessQueueCallback returned without re-arming the ProcessQueue timer when the SQLite SELECT failed, so one transient error stopped queued bans from being replayed until the next plugin load.
Summary
Port of upstream sbpp/sourcebans-pp#1571 ("close DataPack ownership leaks"), covering both halves of that fix independently discovered/ported here.
Part 1 — handle leaks (DataPack / Timer / KeyValues)
Follow-up to #21 (port of upstream sbpp#1492), prompted by sbpp/sourcebans-pp#1570 (comment), which flagged 4 remaining
DataPackleaks in this fork'ssbpp_main.sp. That led to a full sweep of every plugin underscripting/*.spfor the same class of bug — resources allocated (DataPack,Timer,KeyValues,Menu,StringMap,ArrayList,SMCParser) and not freed on every code path, or a stored handle overwritten/reset without freeing what was there before.sbpp_checker.sp,sbpp_sleuth.sp,sbpp_report.sp,sbpp_admcfg.sp(and itssbpp_admin_groups.sp/sbpp_admin_users.sp) came back clean on two independent passes.sbpp_main.sp — 4 leaks, 1 double-free (nested
reasonPack/ stalePlayerDataPack/ stalePlayerRecheck), sbpp_comms.sp — 1 double-free + 1 orphaned-timer bug. See the individual commits for the full per-site breakdown.Part 2 — stale client-index reuse (admin/target identity across an async gap)
Auditing the same functions against the other half of upstream sbpp#1571 turned up a second, distinct bug class the handle-leak sweep didn't touch: every ban command (
sm_ban,sm_banip,sm_addban,sm_unban) and the target-list → time → reason menu flow packs the issuing admin's (and, for the menu flow, the target's) raw client index into aDataPackor a per-slot global array, then reads it back after an async SQL query or after the admin steps through several menu screens. SourceMod reuses a freed client slot immediately — if the original admin or target disconnects in that window and a different player connects into the same slot, the stale index now points at an unrelated player.IsClientInGame()/IsClientConnected()alone can't tell "still the same player" from "someone else now"; only a userid survives a disconnect/reconnect as a stable identity.sbpp_comms.spalready carries this pattern (Query_UnBlockSelectreadsadminUserID/targetUserIDand resolves viaGetClientOfUserId()); the last commit bringssbpp_main.sp's ban flow in line with it:g_BanTargetUserId[]parallelsg_BanTarget[]/g_BanTime[]so the target-list → time → reason menu chain (ReasonSelected,HackingSelected,ChatHook's own-reason path) re-validates the target's userid inPrepareBan()before banning, instead of trusting a live-looking but possibly-reused client index.CommandBanIp/CommandUnban/CommandAddBannow pack the admin's userid instead of the raw index;SelectBanIpCallback,InsertBanIpCallback,SelectUnbanCallback,InsertUnbanCallback,SelectAddbanCallbackandInsertAddbanCallbackall resolve it back viaGetClientOfUserId()before using it forShowActivity2()/LogAction()/PrintToChat()— the existingadmin && IsClientInGame(admin)guards stay correct as-is sinceGetClientOfUserId()returns 0 for a slot that moved on.VerifyInsert(both the success path and the primary-DB-failure path that falls back to the SQLite queue) andUTIL_InsertTempBannow re-resolveadminfrom theadminUserIdfield the pack already carried but discarded, andUTIL_InsertTempBanadditionally re-resolves the target fromtargetUserIdbefore deciding whether to kick them.Native_SBBanPlayer'sPrepareBan()call passes the target's current userid — a no-op validation there since that call is synchronous, kept for signature consistency and because the native is public API third-party plugins call.Test plan
DataPack/handle ownership and read-cursor layout — verified by inspection, no cursor-order changes were made beyond what's documented above.sbpp_main.spandsbpp_comms.spwith spcomp and confirm no errors/warnings.DataPack/handle count growth viasm_handlesacross repeated bans and map changes.sm_banmenu flow (target list → time → reason), have the target disconnect and a different player reconnect into the same slot before picking a reason, and confirm the ban is silently skipped rather than applied to the new occupant.sm_addban/sm_banip/sm_unbanfrom an admin who disconnects before the async SELECT/INSERT round trip resolves (and a different player takes their slot); confirm the success/failure chat message is not misattributed to the new occupant.🤖 Generated with Claude Code