Skip to content

Commit c2ab879

Browse files
committed
fix(1439): kick-only flow no longer tells the player they were banned
Right-clicking a player on `?p=servers` and choosing "Kick player" loads the kickit iframe, which until now ran the post-ban-completion code path unconditionally — emitting the rcon kick reason "You have been banned by this server, check $domain for more info" and re-attributing whatever active ban happened to share the SteamID to the kick target's server (UPDATE :prefix_bans SET sid = ... WHERE authid = ... AND RemovedBy IS NULL). The reporter on #1439 saw the wrong message; the silent audit-trail corruption was the worse half of the bug. The fix carries an explicit `mode` signal end-to-end: - The context menu's Kick URL now appends `&mode=kick` (the post-ban iframe embed inside `admin.bans.php`'s "Ban Added" dialog stays on the default `'ban'` mode). - `admin.kickit.php` allowlists `$_GET['mode']` to `'ban'|'kick'` (anything else coerces to `'ban'` — backward-compat with pre-#1439 callers that don't supply the param). - `KickitView` carries the mode through to `page_kickit.tpl` which branches the `<title>` ("Kick player" vs "Ban player"), surfaces a `data-mode` attribute on the container for third-party theme styling, and forwards the value as `mode` on every `kickit.kick_player` JSON call. - `api_kickit_kick_player` re-validates the mode and gates two things on it: the `:prefix_bans` UPDATE is skipped on kick mode (extracted into `_api_kickit_should_update_ban_sid` for testability + clarity), and the rcon kick reason is now "You have been kicked from this server" on kick mode (still "You have been banned by this server, check ..." on ban mode). - The iframe's post-completion redirect lands the operator back on `?p=servers` for the kick flow (where they came from), preserving the existing `?p=admin&c=bans` destination for ban mode. Coverage: - `KickitTest` (15 cases, 82 assertions) — handler-shape coverage for both modes + the unknown-mode coercion, the rcon-message branch via `_api_kickit_build_kick_message`, and the ban-UPDATE gate via the new `_api_kickit_should_update_ban_sid` helper (incl. a static-analysis guard that the handler invokes the helper rather than inlining the `$mode === 'ban'` check around the UPDATE). - `kickit-iframe.spec.ts` adds an E2E case driving the full kick flow: title says "Kick player", `kickit.kick_player` payload carries `mode: 'kick'`, and the post-completion `window.location` flip lands on `index.php?p=servers` (anchored on real `waitForURL` against the actual 5s redirect timer — source-grep on the script body would silently accept a regression that deleted the kick-mode arm). - `server-player-context-menu.spec.ts` updated to expect `&mode=kick` in the Kick item's href. Documentation in AGENTS.md (context-menu prose + "Where to find what" table + regression-guards section) extended to spell the `mode` contract end-to-end so future readers don't re-discover the failure mode. Adversarial reviewer pass also caught: ban-mode UPDATE SQL was incorrectly transcribed as `removetype = 'X'` in AGENTS.md (now `sid = :sid WHERE authid = :authid AND RemovedBy IS NULL`); the new unit tests now also assert the `mode` field is consumed by the handler and not echoed back in the API response. Fixes: #1439
1 parent f2b0bc6 commit c2ab879

10 files changed

Lines changed: 672 additions & 36 deletions

File tree

AGENTS.md

Lines changed: 29 additions & 10 deletions
Large diffs are not rendered by default.

web/api/handlers/kickit.php

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@ function api_kickit_load_servers(array $params): array
4545
* Try to kick a single player on a single server. Used per-row by the
4646
* iframe's per-server JS loop.
4747
*
48+
* The iframe is invoked from two different surfaces with subtly
49+
* different semantics (#1439):
50+
*
51+
* - **`mode === 'ban'`** (default, backward-compat) — the iframe is
52+
* embedded inside the "Ban Added" success dialog on
53+
* `admin.bans.php`. A ban row was JUST inserted; the per-server
54+
* loop's job is to (a) record which server actually executed the
55+
* kick on the just-created ban row (`UPDATE :prefix_bans SET sid`),
56+
* and (b) tell the player they've been banned via the rcon kick
57+
* message so they know to visit the panel for appeals.
58+
* - **`mode === 'kick'`** — the iframe loaded standalone from the
59+
* right-click context menu on the public servers page (`?p=servers`
60+
* via `web/scripts/server-context-menu.js`). There is NO ban row;
61+
* the operator just wants to kick the player without banning. We
62+
* MUST skip the `:prefix_bans` UPDATE here — without `mode`, the
63+
* UPDATE would silently re-attribute ANY of the player's existing
64+
* active bans (`RemovedBy IS NULL`) to whatever server happened to
65+
* be the kick target, corrupting the audit trail (#1439 secondary
66+
* impact). The rcon kick message is also wrong for this flow —
67+
* the player isn't banned and CAN rejoin, so "You have been
68+
* banned…" lies to them (#1439 user-reported symptom).
69+
*
70+
* Unrecognised `mode` values are coerced to `'ban'` (backward-compat
71+
* — old callers that don't supply the param keep working).
72+
*
4873
* @return array{
4974
* status: 'kicked'|'not_found'|'no_connect',
5075
* hostname: string,
@@ -60,6 +85,14 @@ function api_kickit_kick_player(array $params): array
6085
$sid = (int)($params['sid'] ?? 0);
6186
$num = (int)($params['num'] ?? 0);
6287
$type = (int)($params['type'] ?? 0);
88+
$mode = (string)($params['mode'] ?? 'ban');
89+
if ($mode !== 'kick') {
90+
// Strict allowlist: only 'kick' enters the no-UPDATE / kicked-message branch.
91+
// Everything else (including unsupplied + hostile + typo) falls through to
92+
// 'ban' — the historical default before #1439, so old iframe embeds keep
93+
// working without a paired ban-side flip.
94+
$mode = 'ban';
95+
}
6396

6497
// #1423 follow-up #4 — gate the `check` shape BEFORE we reach the
6598
// `SteamID::compare()` call below. For `type === 0` (Steam-ID
@@ -117,28 +150,37 @@ function api_kickit_kick_player(array $params): array
117150
$hostname = '';
118151
}
119152

153+
$kickMessage = _api_kickit_build_kick_message($mode, Host::complete());
154+
$shouldUpdateBan = _api_kickit_should_update_ban_sid($mode);
155+
120156
foreach (parseRconStatus($ret) as $player) {
121157
if ($type === 0) {
122158
if (SteamID::compare($player['steamid'], $check)) {
123-
$GLOBALS['PDO']->query("UPDATE `:prefix_bans` SET sid = :sid WHERE authid = :authid AND RemovedBy IS NULL");
124-
$GLOBALS['PDO']->bind(':sid', $sid);
125-
$GLOBALS['PDO']->bind(':authid', $check);
126-
$GLOBALS['PDO']->execute();
127-
128-
$domain = Host::complete();
129-
rcon("kickid {$player['id']} \"You have been banned by this server, check $domain for more info\"", $sid);
159+
if ($shouldUpdateBan) {
160+
// Track which server executed the kick on the just-created
161+
// ban row. Only meaningful when a ban exists — see the
162+
// docblock and #1439 for the kick-only data-integrity
163+
// concern that motivated gating this UPDATE on `mode`.
164+
$GLOBALS['PDO']->query("UPDATE `:prefix_bans` SET sid = :sid WHERE authid = :authid AND RemovedBy IS NULL");
165+
$GLOBALS['PDO']->bind(':sid', $sid);
166+
$GLOBALS['PDO']->bind(':authid', $check);
167+
$GLOBALS['PDO']->execute();
168+
}
169+
170+
rcon("kickid {$player['id']} \"$kickMessage\"", $sid);
130171

131172
return ['status' => 'kicked', 'sid' => $sid, 'num' => $num, 'hostname' => $hostname, 'ip' => $sdata['ip'], 'port' => $sdata['port']];
132173
}
133174
} elseif ($type === 1) {
134175
if (($player['ip'] ?? null) === $check) {
135-
$GLOBALS['PDO']->query("UPDATE `:prefix_bans` SET sid = :sid WHERE ip = :ip AND RemovedBy IS NULL");
136-
$GLOBALS['PDO']->bind(':sid', $sid);
137-
$GLOBALS['PDO']->bind(':ip', $check);
138-
$GLOBALS['PDO']->execute();
176+
if ($shouldUpdateBan) {
177+
$GLOBALS['PDO']->query("UPDATE `:prefix_bans` SET sid = :sid WHERE ip = :ip AND RemovedBy IS NULL");
178+
$GLOBALS['PDO']->bind(':sid', $sid);
179+
$GLOBALS['PDO']->bind(':ip', $check);
180+
$GLOBALS['PDO']->execute();
181+
}
139182

140-
$domain = Host::complete();
141-
rcon("kickid {$player['id']} \"You have been banned by this server, check $domain for more info\"", $sid);
183+
rcon("kickid {$player['id']} \"$kickMessage\"", $sid);
142184

143185
return ['status' => 'kicked', 'sid' => $sid, 'num' => $num, 'hostname' => $hostname, 'ip' => $sdata['ip'], 'port' => $sdata['port']];
144186
}
@@ -147,3 +189,65 @@ function api_kickit_kick_player(array $params): array
147189

148190
return ['status' => 'not_found', 'sid' => $sid, 'num' => $num, 'hostname' => $hostname, 'ip' => $sdata['ip'], 'port' => $sdata['port']];
149191
}
192+
193+
/**
194+
* Build the rcon `kickid` reason string for the kickit flow.
195+
*
196+
* Factored out of {@link api_kickit_kick_player()} so the
197+
* mode-branching contract is unit-testable without standing up a real
198+
* RCON probe + matching status response (the rcon `kickid` round-trip
199+
* sits behind a UDP socket that the integration test surface
200+
* deliberately doesn't mock — see `web/includes/system-functions.php`
201+
* `rcon()`).
202+
*
203+
* The kick-only branch's message is intentionally short: no domain
204+
* suffix, because a kicked-not-banned player has nothing to look up
205+
* on the panel side and the rcon `kickid` reason field is single-line
206+
* — long suffixes truncate awkwardly in the user's disconnect dialog.
207+
*
208+
* @param 'ban'|'kick' $mode caller is responsible for the allowlist
209+
* (the handler coerces unrecognised values
210+
* to 'ban' before calling here)
211+
* @param string $domain output of {@see Host::complete()} —
212+
* only consumed by the 'ban' branch; passed
213+
* unconditionally so the signature is
214+
* mode-agnostic and easier to unit-test
215+
*/
216+
function _api_kickit_build_kick_message(string $mode, string $domain): string
217+
{
218+
return $mode === 'kick'
219+
? 'You have been kicked from this server'
220+
: "You have been banned by this server, check $domain for more info";
221+
}
222+
223+
/**
224+
* Decide whether to run the `UPDATE :prefix_bans SET sid = :sid …` write
225+
* that records which server executed the kick on the just-created ban row.
226+
*
227+
* Factored out of {@link api_kickit_kick_player()} for the same reason
228+
* {@link _api_kickit_build_kick_message()} was: it pins the
229+
* mode-branching contract in a unit-testable shape WITHOUT standing up
230+
* a real RCON probe + matching status response (the
231+
* `kicked`-branch UPDATE site sits BEHIND `rcon('status', $sid)`'s UDP
232+
* socket, which the integration test surface deliberately doesn't
233+
* mock — see `system-functions.php` `rcon()`). Without this extraction
234+
* the only way to verify "kick mode does NOT mutate ban rows" would be
235+
* to stub `rcon()` itself, which is a separate refactor. The grep-shaped
236+
* static regression guard
237+
* (`KickitTest::testHandlerInvokesShouldUpdateHelperBeforeUpdate`)
238+
* confirms the handler still calls this helper at the right place;
239+
* the unit test confirms the helper returns the right verdict per mode.
240+
*
241+
* @param 'ban'|'kick' $mode caller is responsible for the allowlist
242+
* (the handler coerces unrecognised values
243+
* to 'ban' before calling here)
244+
* @return bool `true` when the handler should run the UPDATE, `false`
245+
* when it should skip it (kick-only flow has no ban row
246+
* to attribute the kick to, and running the UPDATE would
247+
* silently re-attribute ANY of the kicked player's
248+
* existing active bans to the kick-target server)
249+
*/
250+
function _api_kickit_should_update_ban_sid(string $mode): bool
251+
{
252+
return $mode === 'ban';
253+
}

web/includes/View/KickitView.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
* - `$check` / `$type`: pass-through query params forwarded into
2020
* {@link api_kickit_kick_player()} (steam id / ip + integer
2121
* discriminator).
22+
* - `$mode`: `'ban'` (post-ban-kick flow embedded in the
23+
* "Ban Added" iframe on `admin.bans.php`, default) or `'kick'`
24+
* (standalone kick-only flow from the right-click context menu on
25+
* `?p=servers`). Drives both the rendered page heading copy AND
26+
* the `mode` param forwarded to
27+
* {@link api_kickit_kick_player()}. See `web/pages/admin.kickit.php`
28+
* for the URL-param allowlist (#1439).
2229
* - `$servers`: per-row markers for the polling JS; the
2330
* {@link api_kickit_load_servers()} JSON action refreshes the
2431
* rcon-availability flag at runtime.
@@ -37,13 +44,21 @@ final class KickitView extends View
3744

3845
/**
3946
* @param list<array{num:int, ip:string, port:string|int}> $servers
47+
* @param 'ban'|'kick' $mode
48+
*
49+
* `$mode` carries an inline default of `'ban'` so call sites that
50+
* predate #1439 keep working without an audit (the post-ban iframe
51+
* embed in `admin.bans.php` is the canonical "no mode supplied"
52+
* caller). New callers should pass `'kick'` explicitly when they
53+
* mean the standalone kick flow.
4054
*/
4155
public function __construct(
4256
public readonly string $csrf_token,
4357
public readonly int $total,
4458
public readonly string $check,
4559
public readonly int $type,
4660
public readonly array $servers,
61+
public readonly string $mode = 'ban',
4762
) {
4863
}
4964
}

web/pages/admin.kickit.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,26 @@
2828
$num++;
2929
}
3030

31+
// #1439 — allowlist the `mode` URL param so the iframe can tell the
32+
// post-ban-kick flow (admin.bans.php "Ban Added" success dialog,
33+
// default) apart from the kick-only flow (right-click context menu on
34+
// the public servers page, `&mode=kick`). The handler branches on
35+
// this string to (a) skip the `:prefix_bans` UPDATE that's only
36+
// meaningful when a ban row exists, and (b) emit the matching rcon
37+
// kick message ("kicked" vs "banned"). Anything other than 'kick'
38+
// falls through to 'ban' (backward-compat — pre-#1439 callers don't
39+
// supply the param).
40+
$rawMode = (string) ($_GET['mode'] ?? 'ban');
41+
$mode = $rawMode === 'kick' ? 'kick' : 'ban';
42+
3143
$theme->setLeftDelimiter('-{');
3244
$theme->setRightDelimiter('}-');
3345
\Sbpp\View\Renderer::render($theme, new \Sbpp\View\KickitView(
3446
csrf_token: CSRF::token(),
3547
total: count($serverlinks),
3648
check: (string) ($_GET['check'] ?? ''),
3749
type: (int) ($_GET['type'] ?? 0),
50+
mode: $mode,
3851
servers: $serverlinks,
3952
));
4053
$theme->setLeftDelimiter('{');

web/scripts/api-contract.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,25 @@
351351
*/
352352
/**
353353
* Try to kick a single player on a single server. Used per-row by the iframe's
354-
* per-server JS loop.
354+
* per-server JS loop. The iframe is invoked from two different surfaces with
355+
* subtly different semantics (#1439): - **`mode === 'ban'`** (default,
356+
* backward-compat) — the iframe is embedded inside the "Ban Added" success
357+
* dialog on `admin.bans.php`. A ban row was JUST inserted; the per-server
358+
* loop's job is to (a) record which server actually executed the kick on the
359+
* just-created ban row (`UPDATE :prefix_bans SET sid`), and (b) tell the
360+
* player they've been banned via the rcon kick message so they know to visit
361+
* the panel for appeals. - **`mode === 'kick'`** — the iframe loaded
362+
* standalone from the right-click context menu on the public servers page
363+
* (`?p=servers` via `web/scripts/server-context-menu.js`). There is NO ban
364+
* row; the operator just wants to kick the player without banning. We MUST
365+
* skip the `:prefix_bans` UPDATE here — without `mode`, the UPDATE would
366+
* silently re-attribute ANY of the player's existing active bans (`RemovedBy
367+
* IS NULL`) to whatever server happened to be the kick target, corrupting the
368+
* audit trail (#1439 secondary impact). The rcon kick message is also wrong
369+
* for this flow — the player isn't banned and CAN rejoin, so "You have been
370+
* banned…" lies to them (#1439 user-reported symptom). Unrecognised `mode`
371+
* values are coerced to `'ban'` (backward-compat — old callers that don't
372+
* supply the param keep working).
355373
*
356374
* @typedef {Object} ApiKickitKickPlayerRequest
357375
* @typedef {{ status: 'kicked'|'not_found'|'no_connect', hostname: string, ip?: string, port?: string, sid: number, num: number }} ApiKickitKickPlayerResponse

web/scripts/server-context-menu.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,21 @@
354354
// around what's effectively a "fan a single command out
355355
// to every enabled server and close" interaction.
356356
//
357+
// The `&mode=kick` query param (#1439) tells the
358+
// chromeless kickit page (and the JSON action it fans out
359+
// through) that this is a kick-only flow — no ban row
360+
// exists. The handler then (a) skips the
361+
// `:prefix_bans` UPDATE that would otherwise re-attribute
362+
// any of the player's existing active bans to whatever
363+
// server happened to answer first, and (b) emits the
364+
// "You have been kicked from this server" rcon message
365+
// instead of the post-ban "You have been banned by this
366+
// server, check $domain for more info" (which would lie
367+
// to a player who's actually free to rejoin — the
368+
// user-reported #1439 symptom). The post-ban flow on
369+
// `admin.bans.php` deliberately doesn't pass `mode`, so
370+
// it falls through to the default 'ban' branch.
371+
//
357372
// Ban / Block both route through the panel-chromed
358373
// smart-default URLs (`?p=admin&c=bans&section=add-ban&steam=…&type=0`
359374
// / `?p=admin&c=comms&steam=…&type=0`) because they
@@ -384,7 +399,7 @@
384399
menu.appendChild(buildRow({
385400
label: 'Kick player',
386401
icon: 'log-out',
387-
href: 'pages/admin.kickit.php?check=' + encodeURIComponent(steamid) + '&type=0',
402+
href: 'pages/admin.kickit.php?check=' + encodeURIComponent(steamid) + '&type=0&mode=kick',
388403
testid: 'context-menu-kick',
389404
}));
390405

0 commit comments

Comments
 (0)