Skip to content

Commit 7e4875a

Browse files
authored
fix(bans): keep validated SteamID-of-record on IP-type bans; hide synthetic community id (sbpp#1486) (sbpp#1488)
* fix(drawer): hide synthetic community id on IP-type ban detail (sbpp#1486) An IP-type ban stores an empty authid (the SteamID field is intentionally dropped when the operator picks IP type — see testAddIpTypeAlwaysWritesEmptyAuthid). api_bans_detail computes community_id in SQL straight off authid, so an empty authid collapsed the arithmetic to the base 76561197960265728 (STEAM_0:0:0) and the player-detail drawer painted a bogus "Community" id. Gate community_id on the same SteamID::isValidID() check that already guards steam_id / steam_id_3, returning '' so the drawer hides the row. Mirror the gate in api_comms_detail for parity and to cover malformed or empty legacy authids (sbpp#900). No wire-format change for valid authids, so existing snapshots and the api-contract stay valid. * feat(bans): keep validated SteamID-of-record on IP-type bans (sbpp#1486) When an operator fills both the Steam ID and IP fields and picks an IP-type ban, store the SteamID alongside the IP instead of dropping it. The schema has always had separate ip + authid columns; enforcement stays IP-only (the SourceMod plugin matches an IP ban on the ip column alone), so the recorded authid is a record-of-fact surfaced by the ban detail / banlist. The sbpp#1423 validate-before-convert shape gate is preserved on the IP-type branch: a non-empty Steam ID is shape-checked before toSteam2() so garbage returns a structured validation error on the steam field instead of 500ing (the sbpp#1420/sbpp#1423 bug class) or being silently swallowed (the pre-sbpp#1486 drop). An empty Steam ID field records nothing. Covers api_bans_add (JSON) and admin.edit.ban.php (page handler) so the two surfaces can't diverge. Updates the sbpp#1486 drawer guard comment (now covers no-steam IP bans + legacy malformed authids), rewrites the contract test (testAddIpTypeAlwaysWritesEmptyAuthid -> testAddIpTypeKeepsValidatedSteamOfRecord), and reverses the IP-type empty-authid contract + anti-pattern in AGENTS.md in lockstep. * test(bans): fix IP-type steam-of-record newline case (sbpp#1486) The handler trims `$rawSteam` before the shape gate, so a trailing newline (`STEAM_0:0:1\n`) is stripped to a valid `STEAM_0:0:1` and kept, not rejected. Replace the bogus trailing-newline rejection assertion with a non-trimmable mid-string newline (the shape the handler gate actually catches) and add a positive case documenting the trim-then-keep behavior. Aligns AGENTS.md wording with the trim nuance.
1 parent 5356bb0 commit 7e4875a

6 files changed

Lines changed: 252 additions & 126 deletions

File tree

AGENTS.md

Lines changed: 65 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -833,9 +833,15 @@ of the diff ship together or not at all.
833833
`ApiError('validation', …, 'steam')` envelope (each test now
834834
includes the `STEAM_0:0:1\n` / `[U:1:1]\n` / `76561197960265728\n`
835835
newline-bypass cases as #1423 follow-up #4 regression guards);
836-
`web/tests/api/BansTest.php::testAddIpTypeAlwaysWritesEmptyAuthid`
837-
pins the IP-type empty-`authid` contract (valid Steam input +
838-
garbage + newline-bypass all write empty); the kickit / blockit
836+
`web/tests/api/BansTest.php::testAddIpTypeKeepsValidatedSteamOfRecord`
837+
pins the #1486 IP-type Steam-of-record contract (valid Steam input
838+
is kept alongside the IP, no Steam input writes empty, garbage +
839+
non-trimmable malformed shapes — mid-string `\n` — are rejected with
840+
a `validation` envelope on the `steam` field BEFORE any row is
841+
written, while a trailing-newline `STEAM_0:0:1\n` trims to a valid
842+
`STEAM_0:0:1` and is kept; the shape gate still runs on the IP-type
843+
branch so a junk value can't 500 or get stored); the
844+
kickit / blockit
839845
`SteamID::compare()` pre-`isValidID()` gate is pinned by
840846
`KickitTest::testKickPlayerReturnsNotFoundForMalformedSteamId` /
841847
`testKickPlayerReturnsNotFoundForMalformedIp` and
@@ -859,20 +865,25 @@ of the diff ship together or not at all.
859865
the operator's raw input on the bounce so they see exactly
860866
what they typed and can correct the typo without re-typing
861867
everything else).
862-
- `admin.edit.ban.php` ON IP-TYPE bans hard-codes
863-
`$_POST['steam'] = ''` regardless of whatever the operator
864-
typed in the Steam ID field — the column is the *steam id*
865-
of the banned player; on an IP-type ban there is no steam id,
866-
so the canonical value is the schema's `NOT NULL default ''`
867-
empty string (matching `api_bans_add`'s same-PR fix). The
868-
pre-#1423-follow-up-#4 shape (the `82e8c3d2` "canonicalise on
869-
IP-type" nit) preserved the canonical-on-valid case but
870-
failed to suppress the raw-on-invalid case AND continued
871-
writing the canonicalised SteamID into `:authid` for IP-only
872-
bans — both shapes are wrong. The form-side input remains
873-
visible on the IP-type bounce path (re-emit through the
874-
template `placeholder`, NOT a stale value), but the DB write
875-
is divorced from it.
868+
- `admin.edit.ban.php` ON IP-TYPE bans keeps a *validated* Steam
869+
ID-of-record when the operator filled both fields (#1486 — parity
870+
with `api_bans_add`'s IP-type branch), and writes the schema's
871+
`NOT NULL default ''` empty string when the Steam ID field was
872+
left blank. Enforcement stays IP-only (the SourceMod plugin
873+
matches an IP ban on the `ip` column alone), so the stored authid
874+
is inert plugin-side; it exists so the ban detail / banlist can
875+
show which account the IP belonged to. The validate-before-convert
876+
gate is still load-bearing: a non-empty Steam ID is run through
877+
`SteamID::isValidID()` BEFORE `toSteam2()` so a garbage value
878+
bounces with `$validationErrors['steam']` (raw input preserved on
879+
the bounce, same as the Steam-branch typo path) instead of
880+
escaping the converter as a 500 page render. Pre-#1486 the branch
881+
hard-cleared `$_POST['steam'] = ''` regardless of input, dropping
882+
a SteamID the operator deliberately typed; #1486 reversed that to
883+
keep-when-valid. (The older `82e8c3d2` "canonicalise on IP-type"
884+
nit had a worse shape still — it stored a *canonicalised* SteamID
885+
but failed to suppress the raw-on-invalid case; #1486's
886+
validate-then-convert ladder fixes both.)
876887
- `page.submit.php` doesn't call `SteamID::toSteam2()` at all —
877888
it stores the raw user-input verbatim in `:prefix_submissions`
878889
and the moderation queue resolves the canonical form on
@@ -4336,37 +4347,43 @@ contributions without contacting every contributor individually.
43364347
+ `BansTest.php` + `AdminsTest.php` (the
43374348
`"STEAM_0:0:1\n"` / `"[U:1:1]\n"` / `"76561197960265728\n"`
43384349
cases pin the wire-side behavior).
4339-
- Storing the operator-typed Steam ID in `:prefix_bans.authid`
4340-
on an IP-type ban (the `82e8c3d2` "canonicalise valid IDs on
4341-
IP-type bans to match pre-tighter behaviour" nit shape) → the
4342-
`:authid` column is the *steam id* of the banned player. On
4343-
an IP-type ban (`BanType::Ip = 1`) there is no steam id, by
4344-
definition; the canonical "no steam id" value is the schema's
4345-
`NOT NULL default ''` empty string. The `82e8c3d2` nit aimed
4346-
to preserve "pre-tightening behavior" for the case where the
4347-
operator typed a valid-looking SteamID into the form's
4348-
Steam ID box and then flipped the type radio to "IP-type" —
4349-
pre-tightening the unconditional `toSteam2($rawSteam)` would
4350-
have stored the canonicalised value in `:authid` AND the
4351-
unconditional run would have raised on `garbage` and 500'd
4352-
the page. The nit canonicalised the valid case (good
4353-
intention) but didn't suppress the storage path (the bug it
4354-
carried), AND failed to defend the invalid-input branch on
4355-
the IP-type arm (the 500 was still reachable via
4356-
`?type=1&steam=garbage`). The right shape is: hard-code
4357-
`$_POST['steam'] = ''` for `$banType === BanType::Ip` and
4358-
let the operator's form input remain visible only as
4359-
client-side echo (template `placeholder`, NOT a stale value).
4360-
Matches the `api_bans_add` write-side fix in the same PR
4361-
(`$steam = $banType === BanType::Ip ? '' : ...`). The
4362-
asymmetry pre-#1423-follow-up-#4 (page handler stored
4363-
canonicalised, JSON handler stored empty) was its own bug
4364-
class — third-party callers POSTing to the iframe-routed
4365-
page handler vs. the JSON dispatcher produced inconsistent
4366-
DB state for the same logical input. Regression guard:
4367-
`web/tests/api/BansTest.php::testAddIpTypeAlwaysWritesEmptyAuthid`
4368-
(valid Steam input + garbage + newline-bypass all write
4369-
empty `authid` on `type=1`).
4350+
- Storing an UNVALIDATED operator-typed Steam ID in
4351+
`:prefix_bans.authid` on an IP-type ban → as of #1486 an IP-type
4352+
ban DOES keep a Steam ID-of-record when the operator fills both
4353+
fields (the schema has always had separate `ip` + `authid`
4354+
columns; enforcement stays IP-only because the SourceMod plugin
4355+
matches an IP ban on the `ip` column alone, so the stored authid
4356+
is inert plugin-side and exists only so the ban detail / banlist
4357+
can show which account the IP belonged to). What stays forbidden
4358+
is storing it WITHOUT the shape gate: the value MUST pass
4359+
`SteamID::isValidID()` (page handler) / `HANDLER_STRICT_REGEX`
4360+
(JSON handler) BEFORE `toSteam2()`, exactly like the Steam-type
4361+
branch. Skipping the gate re-opens two bug classes — (a) a junk
4362+
value (`?type=1&steam=garbage`) escapes `toSteam2()` as
4363+
`Exception('Invalid SteamID input!')` → 500 (the #1420 / #1423
4364+
follow-up #4 class), and (b) a malformed authid lands on disk and
4365+
the drawer / banlist later derive a synthetic `community_id`
4366+
(`STEAM_0:0:0`) off it (#1486's display bug). The right shape is
4367+
the validate-then-convert ladder: empty Steam ID → write `''`
4368+
(nothing recorded); non-empty → shape-gate → `toSteam2()` → store;
4369+
bad shape → `validation` envelope (JSON) / `$validationErrors['steam']`
4370+
bounce with raw input preserved (page handler). Both surfaces
4371+
(`api_bans_add` + `admin.edit.ban.php`) share the ladder so the
4372+
JSON dispatcher and the iframe-routed page handler can't diverge
4373+
on the same logical input. The pre-#1486 shape hard-cleared
4374+
`authid` for `type=1`, silently dropping a SteamID the operator
4375+
deliberately typed; the older `82e8c3d2` "canonicalise on IP-type"
4376+
nit stored a *canonicalised* value but failed to suppress the
4377+
raw-on-invalid path — both are superseded by the gated keep.
4378+
Regression guard:
4379+
`web/tests/api/BansTest.php::testAddIpTypeKeepsValidatedSteamOfRecord`
4380+
(valid Steam input kept alongside the IP, empty input writes
4381+
empty `authid`, garbage + non-trimmable malformed shapes — mid-string
4382+
`\n` — rejected with a `validation` envelope on the `steam` field
4383+
before any row is written, trailing-newline trims to a valid value
4384+
and is kept) +
4385+
`web/tests/integration/SteamIDValidationOrderTest.php` (pins the
4386+
validate-before-convert order in `admin.edit.ban.php`).
43704387
- Calling `SteamID::compare($a, $b)` (or any other
43714388
`SteamID::*` method that funnels through `toSteam64()` /
43724389
`resolveInputID()`) with operator-controlled input that

web/api/handlers/bans.php

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,29 @@ function api_bans_add(array $params): array
7171
if (!preg_match(SteamID::HANDLER_STRICT_REGEX, $rawSteam)) {
7272
throw new ApiError('validation', 'Please enter a valid Steam ID or Community ID', 'steam');
7373
}
74+
} elseif ($rawSteam !== '') {
75+
// #1486: IP-type ban where the operator ALSO filled the Steam ID
76+
// field. Keep it as a record-of-fact alongside the IP instead of
77+
// silently dropping the input. Enforcement stays IP-only — the
78+
// SourceMod plugin matches an IP ban purely on the `ip` column
79+
// (sbpp_main.sp: `(type=0 AND authid…) OR (type=1 AND ip…)`), so
80+
// this authid is inert plugin-side; it exists only so the ban
81+
// detail / banlist can show which account the IP belonged to.
82+
//
83+
// The shape gate is still load-bearing: an unvalidated
84+
// `toSteam2('garbage')` throws `Invalid SteamID input!` and the
85+
// dispatcher's `Throwable` fallback turns it into a 500 envelope
86+
// (the #1420 / #1423 follow-up #4 bug class). Validate before
87+
// convert so the recorded id can't be corrupt and the add can't
88+
// 500. An empty Steam ID field on an IP ban is fine (no record).
89+
if (!preg_match(SteamID::HANDLER_STRICT_REGEX, $rawSteam)) {
90+
throw new ApiError('validation', 'Please enter a valid Steam ID or Community ID', 'steam');
91+
}
7492
}
75-
// For IP-typed bans the `:authid` column is the *steam id*, of which
76-
// there is none — write empty string regardless of whatever the
77-
// caller passed in `$rawSteam`. Pre-#1423 follow-up #4 the handler
78-
// converted any non-empty `$rawSteam` here without re-running the
79-
// shape gate (which was Steam-branch-only), so a hostile / typo'd
80-
// caller passing `type=1&steam=garbage&ip=1.2.3.4` triggered
81-
// `toSteam2('garbage')` → `Exception('Invalid SteamID input!')` →
82-
// `Api::handle` `Throwable` fallback → 500 envelope (the bug class
83-
// #1420 was supposed to close, surfacing on the IP-type branch the
84-
// original review didn't cover). The page-handler sibling
85-
// (`admin.edit.ban.php`) carries the matching write-side fix.
86-
$steam = $banType === BanType::Ip ? '' : ($rawSteam === '' ? '' : SteamID::toSteam2($rawSteam));
93+
// Steam bans require a SteamID; IP bans keep it only when one was
94+
// typed (empty otherwise). The shape gate above guarantees this
95+
// conversion cannot throw on either branch.
96+
$steam = $rawSteam === '' ? '' : SteamID::toSteam2($rawSteam);
8797
if (empty($ip) && $banType === BanType::Ip) {
8898
throw new ApiError('validation', 'You must type an IP', 'ip');
8999
}
@@ -891,10 +901,21 @@ function api_bans_detail(array $params): array
891901
$state = 'active';
892902
}
893903

904+
// An IP-type ban carries a SteamID only when the operator typed one
905+
// (#1486 made it a record-of-fact); otherwise authid is empty. Some
906+
// legacy rows also hold malformed authids (#900). Gate steam2 on the
907+
// shape check so toSteam3() / the community id below never derive a
908+
// synthetic value from an empty or junk authid.
894909
$steam2 = $authid !== '' && SteamID::isValidID($authid) ? $authid : '';
895-
// Some legacy rows hold malformed authids (#900); fall back to a
896-
// canonical placeholder so toSteam3() doesn't blow up the response.
897910
$steam3 = $steam2 !== '' ? (string)SteamID::toSteam3($steam2) : '';
911+
// #1486: community_id is computed in SQL straight off BA.authid, so an
912+
// empty authid (IP-type ban with no recorded SteamID) or a malformed
913+
// one collapses the arithmetic to the base 76561197960265728
914+
// (STEAM_0:0:0) — the bogus "Community" id the drawer used to paint.
915+
// Gate it on the same validity check as steam2/3 so it surfaces ONLY
916+
// when there's a real SteamID behind it (Steam bans, or IP bans where
917+
// the operator recorded one).
918+
$communityId = $steam2 !== '' ? (string)$row['community_id'] : '';
898919

899920
$removedByName = null;
900921
if ($row['RemovedBy'] !== null && (int)$row['RemovedBy'] > 0 && !$hideAdmin) {
@@ -942,7 +963,7 @@ function api_bans_detail(array $params): array
942963
'type' => $type,
943964
'steam_id' => $steam2,
944965
'steam_id_3' => $steam3,
945-
'community_id' => (string)$row['community_id'],
966+
'community_id' => $communityId,
946967
'ip' => $hideIps || $banIp === '' ? null : $banIp,
947968
'country' => !empty($row['country']) && trim((string)$row['country']) !== '' ? (string)$row['country'] : null,
948969
],

web/api/handlers/comms.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,13 @@ function api_comms_detail(array $params): array
503503

504504
$steam2 = $authid !== '' && SteamID::isValidID($authid) ? $authid : '';
505505
$steam3 = $steam2 !== '' ? (string)SteamID::toSteam3($steam2) : '';
506+
// #1486: community_id is computed in SQL straight off C.authid. A
507+
// comm block can't be IP-typed, but a malformed/empty legacy authid
508+
// (#900) collapses the arithmetic to the base 76561197960265728
509+
// (STEAM_0:0:0). Gate it on the same validity check as steam2/3 so the
510+
// drawer never paints a synthetic "Community" id (parity with
511+
// api_bans_detail).
512+
$communityId = $steam2 !== '' ? (string)$row['community_id'] : '';
506513

507514
$removedByName = null;
508515
if ($row['RemovedBy'] !== null && (int)$row['RemovedBy'] > 0 && !$hideAdmin) {
@@ -553,7 +560,7 @@ function api_comms_detail(array $params): array
553560
'name' => (string)$row['name'],
554561
'steam_id' => $steam2,
555562
'steam_id_3' => $steam3,
556-
'community_id' => (string)$row['community_id'],
563+
'community_id' => $communityId,
557564
// Comm rows don't store an IP — `:prefix_comms` has no `ip`
558565
// column. Always null; the field is only here so the
559566
// drawer's `renderOverviewPane` can read `player.ip`

web/pages/admin.edit.ban.php

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -143,22 +143,16 @@ function emitEditBanToastAndRedirect(string $kind, string $title, string $body,
143143
// pass. Empty input is its own error message; non-empty-but-bad
144144
// is "please enter a valid…".
145145
//
146-
// The IP-type branch writes `:authid = ''` regardless of whatever
147-
// happened to be in the Steam ID input. The column is the *steam
148-
// id* of the banned player; on an IP-type ban there is no steam
149-
// id, so the canonical value is the schema's `NOT NULL default ''`
150-
// empty string — matching the API handler (`api_bans_add`'s
151-
// INTENT pre-#1423 follow-up #4, fully enforced after that PR)
152-
// and matching the v1.x library's "throw on garbage" outcome
153-
// (pre-tightening the unconditional `toSteam2($rawSteam)` would
154-
// have raised on `garbage` and the page died with a 500; storing
155-
// user-typed garbage into `:authid` is a regression introduced
156-
// by the 82e8c3d2 "canonicalise valid IDs on IP-type bans" nit
157-
// that preserved the canonical-on-valid case but failed to
158-
// suppress the raw-on-invalid case). The form-side input remains
159-
// visible on the IP-type bounce path (re-emit through the
160-
// `placeholder` on the template, NOT a stale value), but the DB
161-
// write is divorced from it.
146+
// #1486: the IP-type branch keeps the Steam ID as a record-of-fact
147+
// when the operator filled both fields, instead of dropping it. The
148+
// SourceMod plugin enforces an IP ban on the `ip` column ONLY
149+
// (sbpp_main.sp: `(type=0 AND authid…) OR (type=1 AND ip…)`), so the
150+
// stored authid is inert plugin-side; it exists so the ban detail /
151+
// banlist can show which account the IP belonged to. The shape gate
152+
// still runs before `toSteam2()` so a garbage value can't escape as
153+
// an uncaught Exception (500 page render) — same validate-then-convert
154+
// ladder as the Steam branch, mirrored in `api_bans_add`. An empty
155+
// Steam ID field on an IP ban records nothing.
162156
if ($postBanType === BanType::Steam) {
163157
if ($rawSteam === '') {
164158
$error++;
@@ -178,14 +172,22 @@ function emitEditBanToastAndRedirect(string $kind, string $title, string $body,
178172
// through the shared `ID_PATTERNS` table.
179173
$_POST['steam'] = \SteamID\SteamID::toSteam2($rawSteam);
180174
}
181-
} else {
182-
// IP-type ban: clear the steam slot for the DB write below
183-
// (`:authid = $_POST['steam']` rides this). Whatever the
184-
// operator happened to type in the Steam ID field stays
185-
// visible in `$rawSteam` for any client-side echo, but it
186-
// does NOT land in the DB. See the block comment above for
187-
// the schema rationale.
175+
} elseif ($rawSteam === '') {
176+
// IP-type ban, no Steam ID typed: nothing to record.
188177
$_POST['steam'] = '';
178+
} elseif (!\SteamID\SteamID::isValidID($rawSteam)) {
179+
// IP-type ban with a typed Steam ID-of-record: validate its shape
180+
// before converting so a garbage value can't escape `toSteam2()`
181+
// as an uncaught Exception. Bounce with the raw input preserved,
182+
// same as the Steam-branch typo path above.
183+
$error++;
184+
$validationErrors['steam'] = 'Please enter a valid Steam ID or Community ID';
185+
$_POST['steam'] = $rawSteam;
186+
} else {
187+
// Keep the canonical Steam ID alongside the IP (record-of-fact;
188+
// enforcement is IP-only). Parity with `api_bans_add`'s IP-type
189+
// branch.
190+
$_POST['steam'] = \SteamID\SteamID::toSteam2($rawSteam);
189191
}
190192

191193
if ($error === 0 && empty($_POST['ip']) && $postBanType === BanType::Ip) {

0 commit comments

Comments
 (0)