Skip to content

fix(game): close DataPack ownership leaks and stale client-index reuse in the ban flow - #40

Merged
Rushaway merged 7 commits into
mainfrom
fix/datapack-leaks-ban-flow
Sep 26, 2026
Merged

Rushaway merged 7 commits into
mainfrom
fix/datapack-leaks-ban-flow

Conversation

@Rushaway

@Rushaway Rushaway commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

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 DataPack leaks in this fork's sbpp_main.sp. That led to a full sweep of every plugin under scripting/*.sp for 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 its sbpp_admin_groups.sp/sbpp_admin_users.sp) came back clean on two independent passes.

sbpp_main.sp — 4 leaks, 1 double-free (nested reasonPack / stale PlayerDataPack / stale PlayerRecheck), 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 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 — 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.sp already carries this pattern (Query_UnBlockSelect reads adminUserID/targetUserID and resolves via GetClientOfUserId()); the last commit 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 — 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

  • Read through each changed call site to confirm DataPack/handle ownership and read-cursor layout — verified by inspection, no cursor-order changes were made beyond what's documented above.
  • Compile sbpp_main.sp and sbpp_comms.sp with spcomp and confirm no errors/warnings.
  • On a live/dev server: issue a ban via command with a reason, via the reason menu, and cancel the reason menu after the target disconnects; confirm no DataPack/handle count growth via sm_handles across repeated bans and map changes.
  • Issue two no-reason bans from the same admin back-to-back (second one before picking a reason for the first) and confirm no handle growth.
  • Trigger an unsilence on a target that also has an active temp mute or gag and confirm no invalid-handle error/crash.
  • Mute/gag a target twice in a row (e.g. via on-connect DB restore with more than one active row) and confirm only one expire timer survives per punishment type.
  • Start a sm_ban menu 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.
  • Issue sm_addban/sm_banip/sm_unban from 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

Rushaway and others added 2 commits September 18, 2026 22:46
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>
@Rushaway Rushaway changed the title fix(game): clean up nested DataPack handles in the ban flow fix(game): clean up DataPack handle bugs in the ban/comms flow Sep 18, 2026
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>
@Rushaway Rushaway changed the title fix(game): clean up DataPack handle bugs in the ban/comms flow fix(game): clean up DataPack/Timer/KeyValues handle bugs across the plugin suite Sep 18, 2026
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>
@Rushaway Rushaway changed the title fix(game): clean up DataPack/Timer/KeyValues handle bugs across the plugin suite fix(game): close DataPack ownership leaks and stale client-index reuse in the ban flow Sep 22, 2026
…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>
@Rushaway

Copy link
Copy Markdown
Member Author

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:

  1. ProcessQueueCallback re-INSERTs a matured queue row against the wrong database connection. It built and executed the %!s_bans INSERT against db (the local SQLite queue handle this callback fires on) instead of DB (the main panel database %!s_bans actually lives on). SQLite has no such table, so the INSERT fails every time — AddedFromSQLiteCallback's results == null branch just re-arms the temporary in-game hold and leaves the row queued forever. A ban recorded during a brief main-DB outage would never actually land in the permanent table once the database came back up, silently defeating the entire point of the offline queue. Fixed both the .Format() call and the final .Query() to target DB, paired with a DB == INVALID_HANDLE guard at the top of the callback (matching upstream's own fix in the same area) so a still-down DB doesn't trade "fails silently" for "throws a native error".

  2. AddedFromSQLiteCallback's RemoveBan(auth, BANFLAG_AUTHID) can miss the temp hold it's trying to clear. The temporary in-game ban was created via SBPP_BanIdentity(), which converts to SteamID3 whenever it can (this is the Synergy workaround this branch's earlier commits are about) — removing by the raw Steam2 string alone silently misses a SteamID3-keyed entry, leaving a stale temp ban sitting in SourceMod's cache until it expires on its own (~ProcessQueueTime minutes, so low severity, but still wrong). Now tries both identity forms.

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.
@Rushaway
Rushaway merged commit e575137 into main Sep 26, 2026
3 checks passed
@Rushaway
Rushaway deleted the fix/datapack-leaks-ban-flow branch September 26, 2026 13:22
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