fix(steamid): surface validation errors across comms / bans / admin add + edit forms (#1420)#1423
Merged
Merged
Conversation
…1420) The "Add a block" form silently no-op'd on invalid SteamID input — the reporter typed garbage in the SteamID field, clicked submit, and got no notification anywhere. Two-layer regression: 1. The submit button was wired to a global `ProcessBan()` defined inline in `admin.comms.php`'s tail script. ProcessBan walked the form via MooTools-era `$('id')` selectors (still working at runtime via `sb.js`'s `global.$` shim that wraps `document.getElementById`), validated the SteamID client-side, and emitted feedback through `sb.message.show` / `sb.message.error`. Those helpers paint into `#dialog-placement` / `#dialog-title` / `#dialog-content-text` — v1.x chrome ids the v2.0 theme doesn't render anywhere. Every error path silently no-op'd against missing DOM targets; the operator saw nothing. 2. Server-side, `api_comms_add` called `SteamID::toSteam2($raw)` unconditionally. The SteamID lib's `resolveInputID` throws a generic `\Exception` for unrecognised input shapes; the dispatcher's `Throwable` fallback in `Api::handle` wraps that as a generic `server_error` envelope (HTTP 500), NOT a structured `validation` ApiError with `field=steam`. So even if the chrome HAD painted the toast correctly, the operator would have seen "Internal server error" instead of "Please enter a valid Steam ID". `api_bans_add` had the same server-side gap on its STEAM-type branch — the only reason it surfaced visible feedback at all was that the bans-add form's IIFE was already on the modern `window.SBPP.showToast` path (the comms-add form was never migrated). Closing one half without the other would have left the bans surface still vulnerable to the same 500-cascade if a hostile/curl-driven caller bypassed the form. The fix has four layers, top-to-bottom: * **Form template (sbpp2026 chrome twin)** — `web/themes/default/page_admin_comms_add.tpl` adds: - `required` on the nickname input - `required` + `pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"` + `title` + `aria-describedby` on the steam input, paired with a visible accepted-formats helper line - `required` on the reason `<select>` (the placeholder option carries `value=""` so native HTML refuses to submit when the operator hasn't chosen anything) - An inline IIFE replaces the legacy `onclick="ProcessBan();"`: `data-action="addcomm-submit"` on the button, document-level click delegate, `form.checkValidity()` short-circuits before our handler runs (browser-native popover for empty/wrong-shape inputs), `sb.api.call(Actions.CommsAdd)` on valid input, `window.SBPP.showToast` for both success ("Block Added") and error ("Block NOT Added — <server-supplied message>") with `sb.message` graceful-degradation fallback for third-party themes that strip theme.js. Per-field `#steam.msg` / `#nick.msg` / `#reason.msg` inline anchors still get the error so screen readers and tests anchored on the per-field shape don't miss it. * **Bans-form parity** — `page_admin_bans_add.tpl` gets the same `pattern` / `title` on its steam input (without `required` since IP-typed bans omit it) and `required` on nickname for visual parity with the comms form. The IIFE was already on the modern toast path; only the native-HTML attrs needed to land. * **Server-side gate** — both `api_comms_add` and `api_bans_add` add an explicit `SteamID::isValidID($rawSteam)` check BEFORE calling `SteamID::toSteam2($rawSteam)`. On the fail branch the handlers throw `ApiError('validation', 'Please enter a valid Steam ID or Community ID', 'steam')` which resolves to HTTP 400 with a structured envelope the client-side IIFE's `r.error.field === 'steam'` branch maps to the per-field inline anchor. Empty-string is split out into its own ApiError with a "you must type a Steam ID" message; the bans handler only fires the SteamID gates when `BanType::Steam` is selected (IP-typed bans go through a parallel `inet_pton` gate one branch over). * **Tail-script modernization** — `admin.comms.php`'s `changeReason()` helper that toggles the freeform textarea on the "other" reason branch switches from `$('dreason').style…` to vanilla `document.getElementById('dreason').style…`. The dead `ProcessBan()` global is deleted entirely (replaced by the template-side IIFE). Regression coverage, paired: - `web/tests/api/CommsTest.php::testAddRejectsInvalidSteamIdShape` drives several bad-input shapes (`'asdf'`, `'12345'`, bare whitespace, the literal string `'null'`) through `api_comms_add` and asserts each returns the structured `validation` ApiError with `field=steam`, NOT the generic `server_error` that pre-fix leaked through. - `web/tests/api/BansTest.php::testAddRejectsInvalidSteamIdShapeForType0` mirrors the same shapes against `api_bans_add` with `type=Steam`. - `web/tests/e2e/specs/flows/comms-add-steamid-validation.spec.ts` is the marquee E2E spec (`.serial` describe — five tests share the e2e DB): 1. Empty SteamID → `validity.valueMissing === true`, API is NOT called (counter assertion on `/api.php` POSTs). 2. Malformed SteamID ("asdf") → `validity.patternMismatch === true`, API is NOT called. 3. Curl-style bypass via `page.evaluate(sb.api.call)` → envelope `{ok:false, error:{code:'validation', field:'steam'}}`, NOT `code:'server_error'`. This is the regression test for the original 500-cascade bug. 4. Happy path → toast paints (`[data-testid="toast"] [data-kind="success"][role="status"]`) and the API returns `ok:true`. 5. Third-party-theme-bypass (form's `required` + `pattern` stripped via JS, simulating a theme that drops them) → server still rejects, IIFE's error branch paints `[data-testid="toast"][data-kind="error"][role="alert"]` carrying "Block NOT Added — Please enter a valid Steam ID …" + per-field inline error mirror. The five-step E2E shape proves end-to-end that the four-layer fix actually closes the loop: the native gate stops common-case mistakes before the API is hit, the server gate stops curl/third-party-theme bypasses, and the toast/inline anchors together surface the failure to every operator-visible feedback surface the v2.0 chrome ships. Docs in the same PR per project convention: new "JSON API" convention bullet on the `SteamID::isValidID()` gate (with canonical code shape) and a matching Anti-patterns entry explaining the failure mode the gate prevents. AGENTS.md is prescriptive; the "Where to find what" table didn't need a row (the contract is bullet-pointed in JSON API + Anti-patterns). Out of scope (deliberate): no GET-fallback for comms-add exists (every submission rides `Actions.CommsAdd`), so there's no parallel `Sbpp\View\Toast::emit` site to add. The `SteamID` library's slightly-loose regex (`STEAM_0:0:` with empty Z digit passes `isValidID`) is unchanged — fixing it would have broader implications and isn't what the reporter hit. Fixes #1420.
…1420) Adversarial-review follow-up to e3a8106. The implementer's PR fixed the user-visible "no notification" symptom on the comms-add form by adding `SteamID::isValidID()` as a server-side gate, but the bundled helper isn't strict enough to actually close the bypass surface a curl-driven / third-party-theme / API-client caller can drive past the browser's `pattern`-mismatch popover. Three concrete bypass classes the `isValidID()` gate accepts: - `STEAM_0:0:` (empty Z) — the library's `\d*` regex accepts zero digits AND `toSteam2()` round-trips the input unchanged, so an obviously-invalid SteamID lands in `:prefix_admins.authid` / `:prefix_bans.authid` / `:prefix_comms.authid`. - `asdfSTEAM_0:0:123` (substring-bypass) — the regex is unanchored (`STEAM_[0|1]:[0:1]:\d*`, also: `|` inside `[...]` is a literal pipe, not alternation), `isValidID()` accepts the substring AND `toSteam2()` returns the input verbatim. The corrupt value breaks SourceMod admin matching and renders as garbage in the drawer / row chrome. - `asdf 76561197960265728 garbage` (embedded Steam64) — same unanchored substring match AND `toSteam2()` emits a corrupt canonical form (`STEAM_0:0:-38280598980132864` — the negative Z component is the parser eating surrounding bytes during numeric conversion). Replace the `isValidID()` call in all three handlers (`api_comms_add`, `api_bans_add`, `api_admins_add`) with the strict anchored regex the form template's client-side `pattern` attribute already enforces (`^(?:STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17})$`). The three handlers now share the regex byte-for-byte so a future caller (deep-link, JSON client, context-menu handoff) only has to learn one accepted shape. `api_admins_add` was an additional gap the implementer didn't touch: it called `SteamID::toSteam2()` BEFORE any validation, so the same generic-`Exception`-becomes-500 vector existed there as on the comms-add and bans-add paths the PR did fix. Tightened in the same shape (defer `toSteam2()` until after the `preg_match` gate). Adversarial-review-specific changes: - `web/api/handlers/comms.php` + `web/api/handlers/bans.php` + `web/api/handlers/admins.php`: swap `SteamID::isValidID()` for the strict anchored regex; carry an in-file comment block documenting why and pointing at the sibling handlers + AGENTS.md. - `web/api/handlers/admins.php`: defer `SteamID::toSteam2()` until AFTER the strict gate (same bug class the implementer fixed on the other two handlers). - `web/tests/api/CommsTest.php` + `web/tests/api/BansTest.php`: extend `testAddRejectsInvalidSteamIdShape*` to cover the four bypass shapes the strict regex now rejects (`STEAM_0:0:`, `asdfSTEAM_0:0:123`, `asdf 76561197960265728 garbage`, `U:1:1`) on top of the implementer's three obvious cases. The two test files share the same case list so a future regression in either handler fails its own test in isolation. - `web/tests/api/AdminsTest.php`: add `testAddRejectsInvalidSteamIdShape` mirroring the bans + comms shape (the implementer didn't touch this handler; the test is new with this commit). - `AGENTS.md`: rewrite the "SteamID inputs" Conventions block to document the strict regex + the three concrete bypass shapes + the rationale for not relying on `SteamID::isValidID()`. Update the matching Anti-patterns entry to reference `preg_match` instead of `isValidID()`. Add a new anti-pattern entry explicitly calling out the `isValidID()`-alone shape so a future agent can't reintroduce the gap by reading the old guidance. - `web/themes/default/page_admin_comms_add.tpl`: drop two outdated "the legacy default theme keeps a copy" comments. The panel ships exactly one theme (`web/themes/default/`); the comment was misleading and the implementer flagged it as a "left alone" concern that was actually a documentation bug. Tightening `SteamID::isValidID()` itself (anchor the regexes, replace `[0|1]` with `[01]`, require `\d+`) is the right long-term fix but it's a third-party-vendored file (`web/includes/SteamID/`) and a broader audit is needed of its other call sites (every panel surface that reads SteamID input — install wizard, admin.edit.*, plugin handshake, …). That's tracked separately; the per-handler strict `preg_match` is the defence-in-depth that fixes the #1420 bypass surface today without waiting on that audit. Gates run on this commit (worktree-mounted stack): - `./sbpp.sh phpstan` — OK - `./sbpp.sh test` — OK (723 tests, 2904 assertions) - `./sbpp.sh composer api-contract` — no diff - `./sbpp.sh ts-check` — clean - `./sbpp.sh e2e --grep 'comms-add SteamID validation'` — 5 passed - `./sbpp.sh e2e --grep 'comms|admin-edit-comms' --workers=1` — 45 passed - `./sbpp.sh e2e --grep 'bans-add|admin-bans|banlist-getfallback' --workers=1` — 11 passed - `./sbpp.sh e2e --grep 'admin-admins|admins-add' --workers=1` — 20 passed Refs #1420.
) Lift the strict-shape contract from the per-handler `preg_match` gates in `api_comms_add` / `api_bans_add` / `api_admins_add` into `SteamID::isValidID()` itself so every other caller of the library (install wizard search box, `admin.edit.*` page handlers, plugin handshake paths, the protest / submit / banlist search surfaces) gets the same defence by construction. Pre-fix `SteamID::isValidID()` and `SteamID::resolveInputID()` carried parallel `switch (true)` regex tables with `STEAM_[0|1]:[0:1]:\d*` / `\[U:1:\d*\]` / `U:1:\d*` / `\d{17}` shapes. Three structural bugs per pattern: - missing `^…$` anchors → substring matchers. `asdfSTEAM_0:0:123`, `asdf 76561197960265728 garbage`, embedded-Steam2 / embedded-Steam3 payloads all passed `isValidID` AND `resolveInputID()` resolved them — `toSteam2()` then either round-tripped the garbage verbatim into the DB (`:prefix_bans.authid` / `:prefix_admins.authid` / `:prefix_comms.authid`) or emitted corrupt canonical forms (`STEAM_0:0:-38280598980132864` from the embedded-Steam64 shape; the negative Z comes from `(int) 'asdf 76561197960265728 garbage'` being 0, the parser eating the surrounding bytes during numeric conversion); - `[0|1]` / `[0:1]` character classes accepted `|` and `:` as literal chars (`STEAM_|:0:0`, `STEAM_0::0` etc.). The `|` inside a character class is NOT alternation; pre-fix the rule accepted the literal pipe; - `\d*` quantifier accepted zero digits. `STEAM_0:0:` round-tripped through `toSteam2()` unchanged and landed in the DB as an invalid SteamID — the #1420 bug-on-disk. Post-fix the four accepted shapes live in a single `SteamID::ID_PATTERNS` constant the two surfaces share, so a future edit to one cannot drift from the other. Patterns: ^STEAM_[01]:[01]:\d+$ Steam2 (universe-0 + universe-1 both accepted) ^\[U:1:\d+\]$ Steam3 bracketed ^U:1:\d+$ Steam3 bracketless (preserved compat — the conversion methods `trim($_, '[]')` so this shape converts correctly; rejecting it would break wild-typed-input callers) ^\d{17}$ Steam64 Every pattern carries the `D` modifier — without it PHP's `$` matches end-of-string OR a final `\n`, so `STEAM_0:0:1\n` slips through the `^…$` anchor pair AND lands as a trailing-newline string in the DB. That sibling bypass was discovered by the new test suite; pin it explicitly so a future agent dropping the modifier ("simplifies to strict regex shape") fails loudly. The library is in-tree under `web/includes/SteamID/SteamID.php` (NOT in `vendor/`), so the project has already taken ownership of it — the "third-party-vendored" framing in the original #1420 convention block was incorrect. Updated AGENTS.md accordingly: the Conventions block under "SteamID inputs" now reflects that the library is strict AND the per-handler `preg_match` is true defence-in-depth (both layers agree on the accepted shape; keep both). The Anti-patterns block grew a paired entry against loosening `ID_PATTERNS` AND a paired entry against removing the per-handler gate "now that the library is strict". Other caller audit (rg 'SteamID::' web/ game/ docker/): - `web/install/pages/page.5.php` validates with its own regex, does not call `SteamID::isValidID()`. Unaffected. - SourceMod plugins (`game/addons/sourcemod/scripting/*.sp`) extract SteamIDs from SourceMod's `GetClientAuthId` and bind them directly to MySQL queries; the PHP `SteamID` library is panel-side only. Unaffected. - `web/includes/Auth/Handler/SteamAuthHandler.php` extracts a Steam64 from the OpenID URL with its own `/^https?:\/\/steamcommunity\.com\/openid\/id\/(7[0-9]{15,25})$/` regex BEFORE calling `SteamID::toSteam3(...)` — already strict; the new tightening cannot reject any input the OpenID response leg legitimately produces (Steam returns 17-char IDs exclusively). - `web/pages/page.{submit,protest,banlist,commslist}.php` and the `:prefix_*` audit-log SteamID-search paths call `SteamID::isValidID()` / `SteamID::toSteam2()` directly. With the library tightening, every one of these gets the same defence-in-depth shape — Follow-up #2 (separate commit) covers the page handlers that ALSO call `toSteam2()` upstream of any validation (admin.edit.{ban,comms,admindetails}), where the uncaught-Exception → 500 page render bug class needs a different fix shape than the JSON `ApiError(...)` envelope. Test coverage (`web/tests/integration/SteamIDValidationTest.php`, 104 tests / 183 assertions): - 13 valid-input cases (every Steam2 / Steam3 / Steam3-bracketless / Steam64 shape the library has always converted correctly). - 30 invalid-input cases (the three #1420 bypasses, the loose character-class variants, empty / whitespace, bracket mismatches, Steam64 length mismatches, pure garbage, embedded valid IDs of every shape, the newline-bypass sibling). - Byte-for-byte symmetry assertions (`isValidID()` and `resolveInputID()` agree on every accepted AND every rejected input — pre-fix the two surfaces drifted and the asymmetry was the underlying bug class). - `ID_PATTERNS` introspection (the constant exists, contains four entries, every regex is `^…$` anchored with the `D` modifier — pin the structural contract so future edits cannot silently loosen). - PHP-8.x int-input deprecation guard (`SteamID::isValidID(76561197960287930)` via int — pre-fix `preg_match` against an int subject emitted `Deprecated: Passing null to parameter…`; the cast-at-entry-point silences it). The per-handler `preg_match` gates in the three JSON `*_add` handlers stay as-is — both layers agree on the accepted shape and both ship. The plugin / install / SteamAuthHandler / page-handler surfaces all acquire the new defence by virtue of the library tightening; no other edits needed for the library-side fix. Refs #1420
…urfaces (review #1420 follow-up #2) Lift the validate-before-convert contract from the three JSON `*_add` handlers fixed in #1420 proper into the four page-handler form-POST surfaces (and the `importBans` file-upload branch in `admin.bans.php`) that called `SteamID::toSteam2()` BEFORE `SteamID::isValidID()`. Same bug class, different failure mode. # The bug class Pre-fix each surface called `SteamID::toSteam2(trim($_POST['steam']))` as the FIRST statement in the POST branch: - `web/pages/admin.edit.ban.php` L113 (top of `if (isset($_POST['name']))`) - `web/pages/admin.edit.comms.php` L101 (top of `if (isset($_POST['name']))`) - `web/pages/admin.edit.admindetails.php` L67 (`$resolvedSteam = …`) - `web/pages/admin.bans.php` L121 (the `banid` branch of `importBans`) - `web/pages/page.submit.php` never called `toSteam2()`; just gated on the loose `SteamID::isValidID()` check (closed by follow-up #1's library tightening). The converter raised `Exception('Invalid SteamID input!')` from `resolveInputID()` on any non-empty input that failed the shape check; the exception escaped the page handler unhandled, the panel chrome's `PageDie()` never fired, and the user got a generic 500 page render instead of the inline per-field error on the form. The `admin.bans.php` import case was the worst: a single malformed `banid …` line in the operator's uploaded `banned_user.cfg` aborted the entire import (no transaction wrapper) and the operator had no signal as to which line broke or how many of the preceding inserts committed. Follow-up #1's library tightening made the throw stricter (the four ID_PATTERNS regexes are now anchored, `[01]` instead of `[0|1]`, `\d+` instead of `\d*`, with the `D` modifier closing the newline- bypass sibling). Pre-tighter library many garbage inputs round-tripped verbatim into the DB as the "canonical" SteamID via `STEAM_0:0:` / substring-embedded / Steam64-embedded shapes documented in the SteamID inputs Conventions block. Post-tighter library every such input throws — which makes the page-handler 500-page-render shape strictly MORE frequent and reachable from any typo. So follow-up #2 is the necessary paired fix on the consumer side. # The fix shape (validate-then-convert ladder) Per the AGENTS.md "SteamID inputs" → page-handler form-POST surfaces contract (added here): $rawSteam = trim((string) ($_POST['steam'] ?? '')); if ($rawSteam === '') { $validationErrors['steam'] = 'You must type a Steam ID or Community ID'; } elseif (!\SteamID\SteamID::isValidID($rawSteam)) { $validationErrors['steam'] = 'Please enter a valid Steam ID or Community ID'; // Preserve raw input on bounce so operator can fix the typo. $_POST['steam'] = $rawSteam; } else { $_POST['steam'] = \SteamID\SteamID::toSteam2($rawSteam); } Each handler picks the error-surfacing channel its surrounding code already uses (Option B per AGENTS.md "Add a confirm + reason modal"): - `admin.edit.ban.php` → `$validationErrors['steam']` + form re-render (matches existing empty / IP / duplicate paths). - `admin.edit.comms.php` → `$errorFields[] = ['steam.msg', '…']` + page-tail script (matches existing pattern). - `admin.edit.admindetails.php` → `$validationErrors['steam']` + form re-render. The pre-fix `$resolvedSteam !== '' || strlen < 10` empty-check splits into distinct "empty" / "invalid" messages so the operator sees actionable wording. - `admin.bans.php` (importBans branch) → skip-and-count. Counts invalid lines via a `$skipped` counter and surfaces the count in the success toast next to the imported count. `page.submit.php` doesn't call `toSteam2()` at all (the public form stores raw input verbatim in `:prefix_submissions`; the moderation queue does the canonical resolution on accept). The library tightening closes the bypass for this surface; the paired template-side `pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"` + `title="Enter a Steam ID …"` are the client-side defense. The legacy `STEAM_0:` empty-sentinel pre-fill the page handler used to re-emit was dropped in the same edit: the partial sentinel would have failed the new pattern check AND blocked submission for the legitimate IP-only path (which the form explicitly supports). # Templates All four form templates carry the same strict `pattern` + `title` attributes the JSON-flow add-form templates (`page_admin_comms_add.tpl` / `page_admin_bans_add.tpl`) already ship from #1420 proper: - `page_admin_edit_admins_details.tpl` → `required` + pattern - `page_admin_edit_ban.tpl` → pattern only (IP-target bans legitimately leave the Steam ID input empty) - `page_admin_edit_comms.tpl` → `required` + pattern - `page_submitban.tpl` → pattern only (IP alternative) + removed `novalidate` so native validation actually fires client-side The `STEAM_0:` empty-sentinel dead defense in `page_submitban.tpl`'s `isEmpty(el)` helper was dropped in the same template edit (the page handler no longer emits the sentinel; the JS check would never hit). # Test coverage `web/tests/integration/SteamIDValidationOrderTest.php` (9 tests): - `testAdminEditBanValidatesBeforeConverting` - `testAdminEditCommsValidatesBeforeConverting` - `testAdminEditAdminDetailsValidatesBeforeConverting` - `testAdminBansImportValidatesBeforeConverting` - `testPageSubmitUsesIsValidIdNotToSteam2` - `testPageSubmitDroppedSteamZeroSentinel` - `testFormTemplatesCarryStrictSteamPattern` - `testFormTemplatesCarrySteamPatternTitle` - `testPageSubmitFormDoesNotCarryNovalidate` - `testJsonHandlersKeepDefenseInDepthRegex` Pattern lifted from `AdminEditCommsCheckOrderTest` (#1410) — static- shape pins via `strpos($contents, …)` ordering rather than process- isolated runtime tests. The page handlers all run inside the chrome- render pipeline (validation → `Renderer::render` → Smarty → `echo`), which doesn't compose with PHPUnit's `runInSeparateProcess` mode (the helper's `exit;` mid-test breaks the child-process serializer and PHPUnit reports an errored test regardless of the assertion outcome). The static-shape pin catches the bug directly: each test asserts that `isValidID(...)` appears BEFORE `toSteam2(...)` in the file's text; reverse the order and the test fails before the page does in production. The runtime side of the contract is exercised by the existing `comms-add SteamID validation` Playwright spec — the strict-regex contract is shared across the JSON + form surfaces, so the existing E2E coverage exercises the same library boundary the page handlers now route through. # Newly discovered scope The `admin.bans.php` import path (`banid <duration> <STEAMID>` lines) was discovered during the audit and added to the sweep — same bug class, same fix shape, but slightly different error-surface (skip-and-count instead of bounce-with-inline-error since the operation is a bulk import). # Quality gates (all green) ./sbpp.sh phpstan → 241/241 OK ./sbpp.sh test → 837 tests, 3138 assertions OK ./sbpp.sh composer api-contract → no diff (no handler signatures changed) Refs #1420
…low-up #2 nit) The previous commit's `admin.edit.ban.php` reshape had one behavioral asymmetry with pre-tighter-library shape: for IP-type bans (`$postBanType === BanType::Ip`), the pre-fix code ran `SteamID::toSteam2($rawSteam)` unconditionally — so a Steam ID typed in the secondary input always landed in `:authid` in canonical Steam2 form. The validate-before-convert reshape skipped the conversion for IP-type bans entirely, which: - preserved raw input on invalid garbage (the post-tightening safe behavior — `toSteam2()` would now throw on bad input), AND - silently dropped the canonicalisation on VALID inputs (a user typing `[U:1:12345]` for an IP-type ban would land `:authid = '[U:1:12345]'` in the DB instead of the canonical `STEAM_0:1:12345`). Split the IP-type branch into "valid input → canonicalise" (matches pre-tighter behavior for valid inputs) and "empty / invalid → preserve raw" (the new safe behavior for garbage). The branch is gated on `isValidID()` so the `toSteam2()` call cannot throw. `:authid = ''` for IP-type bans is the cleaner long-term shape (matches `api_bans_add`'s explicit `':authid' => ''` for type=1), but flipping the column write to that contract is a separate schema-cleanup follow-up — pre-fix the page handler stored whatever the user happened to type, and matching that unconditionally is the least surprising behavior for #1420. No new tests needed — the existing static-shape guard (`SteamIDValidationOrderTest`) still pins the call-order contract, and the existing e2e suite (passes with `workers=1` matching CI) exercises both the Steam-type and IP-type form paths. Refs #1420
#1420 review follow-up) Second-round review on PR #1423 surfaced two interleaved gaps the first-round fixes didn't fully close. Both ride one commit because `api/handlers/bans.php` carries the fix surface for both — the Steam-branch regex unification AND the IP-type empty-authid contract share the same `api_bans_add` Steam ID handling block. ## #1 — Single source of truth for the handler regex The first-round fix kept hand-rolled `preg_match` literals at each handler call site (admins.php / bans.php / comms.php) as "defense in depth" against the library. But the three copies subtly DIFFERED from the library's `ID_PATTERNS` (and from each other) on the `D` modifier — without it, `STEAM_0:0:1\n` matches the handler's `^…$` anchors (because `$` accepts end-of-string OR just-before-final-`\n` in the default mode) and then fails the library's `SteamID::isValidID()` (which DOES carry `D` post-#1423 follow-up #1), triggering `SteamID::toSteam2()` to throw, which the dispatcher's `Throwable` fallback wraps as a generic 500 envelope. That's precisely the bug class #1420 was supposed to close — surfaced on the substring-bypass surface (`asdf 76561197960265728 garbage`), but the same shape recurs every time the two layers drift. Lift to a single library constant `SteamID::HANDLER_STRICT_REGEX` and have the handlers consume it directly. The constant's docblock spells out the contract: byte-for-byte symmetry with the form template's `pattern` attribute, the load-bearing `D` modifier, and the deliberate asymmetry with `ID_PATTERNS` (bracketless Steam3 `U:1:N` stays a library-side convenience but is rejected at the handler gate to match the form's `pattern="…|\[U:1:\d+\]|\d{17}"` which requires the brackets). Regression coverage: - `SteamIDValidationTest::testHandlerStrictRegexIsExposedAndCarriesDModifier` — pins the constant's existence + the `D` modifier requirement at the library level. - `SteamIDValidationTest::testHandlerStrictRegexAgreesWithIdPatternsOnAcceptableShapes` — every shape the strict regex accepts must also pass the library's `isValidID()` + convert without throwing (handler gate <= library gate). - `SteamIDValidationTest::testHandlerStrictRegexRejectsBracketlessSteam3` — pins the documented asymmetry (library accepts `U:1:N`, handler rejects it for form-pattern symmetry). - `SteamIDValidationTest::testHandlerStrictRegexRejectsNewlineBypass` — explicit `STEAM_0:0:1\n` / `[U:1:1]\n` / `76561197960265728\n` rejection at the regex AND the library `isValidID()` level. - `SteamIDValidationOrderTest::testJsonHandlersUseSingleSourceOfTruthRegex` — replaces the old "defense-in-depth hand-rolled regex literal" assertion with the single-source-of-truth call site contract (`preg_match(SteamID::HANDLER_STRICT_REGEX, …)`). ## #2 — IP-type ban writes empty authid The first-round nit (`82e8c3d2`) "canonicalised valid Steam IDs on IP-type bans to match pre-tighter behaviour". The "pre-tighter behaviour" itself was a bug — the page handler wrote whatever the user typed in the Steam ID input into `:authid` regardless of the ban being IP-typed. The nit fix preserved the canonical-on-valid case but failed to suppress the raw-on-invalid case — `garbage` in the Steam ID input on an IP-type ban still landed in `:authid` verbatim. The JSON API handler (`api_bans_add`) and the page handler (`admin.edit.ban.php`) also disagreed: - api: `$steam = $rawSteam === '' ? '' : SteamID::toSteam2($rawSteam);` — conversion ran on EVERY non-empty input regardless of `$banType`, so `type=1&steam=garbage&ip=1.2.3.4` ran `toSteam2('garbage')` → `Exception('Invalid SteamID input!')` → `Api::handle` `Throwable` fallback → 500 envelope (the bug class #1420 was supposed to close, surfacing on the IP-type branch the original review didn't cover). - page: stored the raw value verbatim (no canonicalization, no rejection) regardless of validity. The correct contract is "IP-type bans have no Steam ID, so `:authid` gets the schema's `NOT NULL DEFAULT ''` empty string regardless of whatever happened to be in the Steam ID input." Both halves of the handler converge on `$steam = $banType === BanType::Ip ? '' : …` (api) and `$_POST['steam'] = ''` (page handler) for the IP branch. The form-side input remains visible on the bounce path (the template re-emits `$rawSteam` for the operator to correct), but the DB write is divorced from it. Regression coverage: - `BansTest::testAddIpTypeAlwaysWritesEmptyAuthid` — three subtests: valid Steam-shape (must store empty, not the canonicalised form), garbage (must NOT 500), trailing-newline bypass (must NOT 500 even before regex-level rejection). ## Documentation `AGENTS.md` updated with three blocks: - The `SteamID inputs` convention section's example code block now references `SteamID::HANDLER_STRICT_REGEX` instead of the hand- rolled literal, with a paragraph documenting the constant's contract (`D` modifier, form-pattern symmetry, documented asymmetry with `ID_PATTERNS`). - The regression-coverage section enumerates every new test under `SteamIDValidationTest.php` and the order test. - New anti-pattern entry: "Hand-rolling the strict SteamID-shape regex literal at the per-handler `preg_match` call site" documenting why the `D` modifier is load-bearing and how the pre-fix drift produced the 500 envelope. - New anti-pattern entry: "Storing the operator-typed Steam ID in `:prefix_bans.authid` on an IP-type ban" documenting the schema contract and the converged page-handler + API write paths.
…ckit (#1420 review follow-up) `api_kickit_kick_player` and `api_blockit_block_player` iterate the A2S `GetPlayers` list and call `SteamID::compare($a, $b)` to match the operator-supplied `check` against each returned player. The library's `compare()` calls `resolveInputID()` which throws `Exception('Invalid SteamID input!')` on a shape it can't parse. Pre-fix the operator-controlled `check` parameter went straight into `compare()` with no shape gate — every malformed input (curl-driven, context-menu handoff with a typo, page reload with stale URL params, …) raised the exception, the dispatcher's `Throwable` fallback wrapped it as a generic `server_error` 500 envelope, and the operator saw "something went wrong" with no actionable feedback. Same bug class #1420 was supposed to close, surfaced on a different handler pair the first-round review didn't audit. The fix is the same shape as `api_bans_add`'s pattern check: validate `check` before calling `compare()`. The handlers carry two arms (`type=0` for Steam ID, `type=1` for IP); pre-validate via: - `SteamID::isValidID($check)` for Steam (rejects malformed Steam2 / Steam3 / Steam64 shapes including the newline-bypass class via the library's `D` modifier-carrying `ID_PATTERNS`) - `filter_var($check, FILTER_VALIDATE_IP)` for IP (rejects everything that isn't a syntactically valid IPv4 or IPv6 address) On a malformed input return the structured `status: 'not_found'` envelope the handlers already emit for "no matching player" — same shape the UI's "this player isn't here right now" branch already handles, no new error path needed downstream. Blockit only carries the Steam arm (comms-block is always Steam-ID- keyed); kickit carries both because the RCON kick command can target either a SteamID or an IP. Regression coverage: - `KickitTest::testKickPlayerReturnsNotFoundForMalformedSteamId` — five subtests: pure garbage, empty-Z (`STEAM_0:0:`), trailing-newline bypass (`STEAM_0:0:1\n`), invalid universe (`STEAM_2:0:1`), empty string. Each must return `not_found` (NOT a 500 envelope). - `KickitTest::testKickPlayerReturnsNotFoundForMalformedIp` — five subtests: dotted-but-out-of-range, non-numeric, missing octet, trailing-garbage, empty string. Each must return `not_found`. - `BlockitTest::testBlockPlayerReturnsNotFoundForMalformedSteamId` — five subtests mirroring kickit's Steam arm.
…pattern (#1420 review follow-up) Two surfaces the first-round audit didn't cover, both feeding the same `SteamID::toSteam2()` library call from upstream regexes that drifted on shape and modifier. ## `Sbpp\Auth\Handler\SteamAuthHandler::validate()` Steam OpenID returns the user's Steam64 in the claimed_id URL path (`https://steamcommunity.com/openid/id/<76561197960265728>`). The handler regex `7[0-9]{15,25}+` was wider than reality — Steam in practice always returns exactly 17-digit Steam64s for individual accounts, and the library's `ID_PATTERNS` carries `^\d{17}$D` for the Steam64 arm. Pre-fix a 16-digit OR 18-25-digit OpenID claim slipped past `validate()`'s extract regex but then failed `SteamID::toSteam2()` in `check()` (which routes through `isValidID()`'s strict `\d{17}` gate). The exception escaped the constructor's call site unhandled (LightOpenID's mid-flow redirect leaves no chrome to catch it), so the operator landed on a 500 mid-Steam-login round-trip — silent failure mode, no toast, no inline error. Tightened to `7\d{16}+` with the `D` modifier so the two gates agree on the shape byte-for-byte. Added a `SteamID::isValidID()` belt-and- suspenders gate in `check()` so any future drift on either side surfaces as a clean redirect-to-login-failed, not as a 500. ## `web/install/pages/page.5.php` The install wizard's "create initial admin" step had its own regex (`/^STEAM_[01]:[01]:[0-9]+$/`) for the Steam ID input on the admin form. Pre-fix this carried no `D` modifier, so `STEAM_0:0:1\n` (a copy-paste-from-game-console typo with a trailing newline) slipped past the wizard's gate but then failed `SteamID::isValidID()` on the post-install panel runtime side (different code path, same library). Result: the wizard accepted the input, the admin row got created with a malformed authid, and the first panel login attempt would fail with no recoverable error path (the wizard can't be re-run over an installed panel — see the `already-installed.php` guard). Added the `D` modifier so the wizard rejects the newline-bypass at the same gate the panel runtime does. Regression coverage (added in commit 1): - `SteamIDValidationTest::testSteamAuthHandlerOpenIdRegexAcceptsOnly17DigitsStartingWith7` — 16-digit, 18-digit, non-7-starting, trailing newline, trailing garbage rejections. - `SteamIDValidationTest::testInstallWizardSteamIdRegexCarriesDModifier` — pins the install wizard's regex (Steam2-only, D modifier rejects trailing newline, [01] rejects universe 2 / Y=2).
…1420 review follow-up) The `_register.php` dispatcher `trim()`s string params before the handler sees them, so trailing whitespace (the most-likely typo shape) gets cleaned upstream of `SteamID::HANDLER_STRICT_REGEX`'s gate. The library-side tests (`SteamIDValidationTest::testHandlerStrictRegexRejectsNewlineBypass`) already cover trailing newlines at the regex level, but the handler- level tests should cover the shapes `trim()` does NOT defend against: - Mid-string newlines (`STEAM_0:0:1\nGARBAGE`, `GARBAGE\nSTEAM_0:0:1`) — embedded newlines pass through `trim()` and must be caught at the regex layer's `^…$` anchors + non-`m` mode requiring whole- string match. - Trailing non-breaking spaces (`STEAM_0:0:1\xC2\xA0` = U+00A0) — `trim()` only handles the ASCII whitespace set (space / tab / CR / LF / vertical tab / null), so unicode whitespace like NBSP / zero- width-space / ideographic-space passes through and must be caught at the regex layer too. The pre-existing trailing-newline test cases (`"STEAM_0:0:1\n"`, `"[U:1:1]\n"`, `"76561197960265728\n"`) were quietly redundant — the dispatcher's `trim()` consumed them before the regex got a turn, so the tests proved nothing about the gate's actual rejection behaviour. Replaced with the trim-bypass shapes above so the handler tests actually exercise the regex's load-bearing `^…$` anchors. The library-side coverage stays — `SteamAuthHandler::validate()` and the install wizard's `page.5.php` DON'T `trim()` their inputs (the OpenID claimed_id and the install form value go straight into the regex), so the trailing-newline cases there are non-redundant. See the matching contracts pinned by `SteamIDValidationTest`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Eight commits — the implementer's original fix (
e3a8106d), anadversarial-review follow-up (
821e81f4) closing a server-sidebypass surface the original fix left open, three further follow-ups
(
fd81750b+9555f4f8+82e8c3d2) that incorporate the three"out of scope" items from the original review into this same PR,
and four second-round review fixes (
2f1fe61e+da583d31+fccb177b+6a018c44) that closed gaps the first-round reviewmissed (per-handler regex drift on the
Dmodifier, IP-type ban:authidstorage divergence, unvalidated input toSteamID::compare()in kickit/blockit, an overly permissive OpenID regex in
SteamAuthHandler, and a missingDmodifier in the installwizard's Steam-ID gate).
The user-visible bug. Per issue #1420 (reporter: iBoonie), the
comms-add form silently swallowed every invalid SteamID. The form's
inline tail script wired the submit button to a legacy
ProcessBan()helper that called into
sb.message.showagainst the v1.x#dialog-placementchrome shell — DOM ids the v2.0 theme doesn'trender anywhere. Net effect: every error branch silently no-op'd
client-side, and the server-side
SteamID::toSteam2()call threw ageneric
\Exceptionthat the dispatcher's catch-all wrapped as a500 with body "An unexpected error occurred" the chrome treated as
a server outage, not a per-field validation failure.
Commits
e3a8106d fix(comms): surface SteamID validation errors on the comms-add form (#1420)821e81f4 fix(comms): close substring-bypass via strict SteamID regex (review #1420)fd81750b fix(steamid): tighten SteamID library regexes (review #1420 follow-up #1)9555f4f8 fix(steamid): validate-before-convert across page-handler form-POST surfaces (review #1420 follow-up #2)82e8c3d2 fix(steamid): canonicalise valid Steam IDs on IP-type bans (#1420 follow-up #2 nit)2f1fe61e fix(steamid): unify handler regex + write empty authid on IP-type bans (#1420 review follow-up)da583d31 fix(steamid): pre-validate inputs to SteamID::compare in kickit / blockit (#1420 review follow-up)fccb177b fix(steamid): tighten SteamAuthHandler OpenID regex + install wizard pattern (#1420 review follow-up)6a018c44 test(steamid): cover trim-bypass shapes in admins / comms add tests (#1420 review follow-up)Follow-up #1: tighten
SteamID::isValidID()itself (fd81750b)The reviewer flagged
web/includes/SteamID/SteamID.php::isValidIDas structurally unsafe: regexes were unanchored, used
[0|1]character classes (where
|is a literal pipe, not alternation),and used
\d*(accepts zero digits). The follow-up:SteamID::ID_PATTERNSconstant. Both
isValidID()andresolveInputID()now consumethe same table, so they cannot drift again. Patterns:
^STEAM_[01]:[01]:\d+$D— Steam2^\[U:1:\d+\]$D— Steam3 bracketed^U:1:\d+$D— Steam3 bracketless (preserved compat)^\d{17}$D— Steam64Dmodifier closes theSTEAM_0:0:1\nnewline-bypasssibling (without
D, PHP's$matches end-of-string OR a final\n).web/includes/SteamID/(NOTvendor/) — the project owns it, the "third-party-vendored"framing in the original review was incorrect.
rg 'SteamID::' web/ game/ docker/):admin.edit.*page handlers,page.submit.php,page.protest.php,page.banlist.php,page.commslist.php,SteamAuthHandler.php— all unaffected (their happy pathsproduce inputs the strict regex still accepts).
GetClientAuthIdandbind directly to MySQL queries; the PHP library is panel-side
only.
*_addhandlers KEEP their per-handlerpreg_matchas defence-in-depth (both layers agree on the accepted shape;
the library is one refactor away from someone "simplifying" the
shared table back to a loose form, so the per-handler gate
catches that regression).
web/tests/integration/SteamIDValidationTest.php(104tests / 183 assertions) — drives every Comms block steamID validation - No notification on invalid steamID #1420 bypass shape +
loose-character-class variants + empty / whitespace / embedded
IDs / newline-bypass, plus
ID_PATTERNSintrospection and thePHP-8.x int-input deprecation guard.
Follow-up #2: page-handler form-POST surfaces (
9555f4f8+82e8c3d2)The four form-POST surfaces that called
SteamID::toSteam2()BEFORE validation had the same bug class as the JSON handlers,
but with a different failure mode: the converter's
Exception('Invalid SteamID input!')escaped the page handlerunhandled, the chrome's
PageDie()never fired, and the usergot a generic 500 page render instead of the inline per-field
error on the form. Follow-up #1's library tightening made the
throw stricter — which made the 500 page render strictly MORE
frequent on every surface that hadn't been swept yet.
Surfaces swept:
web/pages/admin.edit.ban.php— validate-then-convertladder, error into
$validationErrors['steam'], raw inputpreserved on bounce.
web/pages/admin.edit.comms.php— same ladder, error into$errorFields[] = ['steam.msg', '…'].web/pages/admin.edit.admindetails.php— same ladder, errorinto
$validationErrors['steam']; the$resolvedSteam/$steamIsValidShapedistinction surfaces empty vs invalid asdistinct messages.
web/pages/page.submit.php— already gated onisValidID();the library tightening closes the bypass without a handler edit.
The legacy
STEAM_0:empty-sentinel pre-fill (which would havefailed the new template
pattern="…"check) was dropped.web/pages/admin.bans.php'simportBansbranch — discoveredduring the audit, same bug class. A single malformed
banid …line in the operator's uploaded
banned_user.cfgaborted theentire import mid-file (no transaction wrapper) and the operator
had no signal as to which line broke. Validate-before-convert
skips and counts malformed lines; the success toast surfaces the
skipped count alongside the imported count.
Template-side changes (matching the JSON-flow add-form templates
from the original #1420 PR):
pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}"+ actionabletitle="…"attributes on the Steam ID input.page_admin_edit_admins_details.tpl+page_admin_edit_comms.tplalso carry
required+aria-required="true"(every admin rowmust carry an
:authid; every comm-block must target aSteamID).
page_admin_edit_ban.tpl+page_submitban.tplare intentionallyNOT
required— both legitimately accept an empty Steam IDwhen the operator provides an IP instead.
page_submitban.tpldroppednovalidateso native validationactually fires client-side; the cross-field "one of Steam ID OR
IP" guard stays in JS (HTML can't express it natively).
Follow-up #3: lift shared helper for the two add-form IIFEs — considered, deferred
The reviewer asked whether the two add-form IIFEs (
page_admin_comms_add.tpland
page_admin_bans_add.tpl) could be consolidated into a sharedweb/scripts/admin-add-form.jshelper. After diffing the twoIIFEs in detail:
Shared (~35 lines, ~25% of each IIFE):
api()/actions()/$id()/setMsg()/toast()/setBusy()helper functions are identical byte-for-byte(graceful-degradation wrappers for when
theme.jsis strippedby a third-party theme).
Divergent (~75% of each IIFE, fundamentally different logic):
form.checkValidity()(every input is unconditionally required); bans-add uses an
ad-hoc
validate(type)function for type-conditional steam-vs-IPvalidation (native HTML can't express the type-conditional
gate).
(adds
ip,dfile,dname,fromsub).admin.blockit.phpto fire RCON; bans-add callsShowKickBox/TabToReload+ resets the form in place.error.fieldto specific*.msgdivs (steam.msg / nick.msg / reason.msg); bans-addjust toasts.
page-reloads; bans-add re-enables button + form.reset().
Why deferred:
the 25% boilerplate would force a parameterised helper API
(
validatecallback,harvestcallback,onSuccesscallback,onErrorFieldmap) that would leak the divergence throughthe helper's parameter shapes — replacing 35 lines of
duplication with 50+ lines of indirection that's harder to
read than the inlined IIFEs.
explicitly says inline page-tail scripts should define
setBusy(btn, busy)as a local wrapper that delegates towindow.SBPP.setBusywhen present and falls back to plaindisabledotherwise. The fallback is what keeps thedouble-click gate working on third-party themes that strip
theme.js. Lifting the wrapper to a shared file atweb/scripts/admin-add-form.jswould fight that contract(a third-party theme stripping
theme.jswould also stripthe new shared script).
validation); locking them into a shared API now would make
future per-form tweaks more painful.
this as "Not blocking; the two forms now both fail closed
on invalid input via the native browser popover + the
server-side strict regex."
The reviewer's heuristic — "If the result is meaningfully MORE
complex or LESS readable than the two inlined IIFEs (a common
refactor anti-pattern), STOP and report back that #3 is
'considered, not landed'" — applies here. Filing this as
deferred rather than forced.
AGENTS.md updates
library tightening (the
preg_matchis now truedefence-in-depth alongside
SteamID::isValidID()).documenting the validate-then-convert ladder shape across
admin.edit.*/page.submit.php/importBans.SteamID::ID_PATTERNS(back to substring matcher/
[0|1]/\d*).preg_match"now that the libraryis strict too".
SteamID::toSteam2()beforeSteamID::isValidID()on a page handler's raw POST input (the convert-before-
validate bug class).
toSteam2()intry/catch (\Exception)as asubstitute for the upstream
isValidID()gate.per-handler
preg_matchcall site (now sourced fromSteamID::HANDLER_STRICT_REGEX).:prefix_bans.authidon an IP-type ban (must be
''per the schema'sNOT NULL DEFAULT '').SteamID::compare(…)with operator-controlled inputthat hasn't been gated through
SteamID::isValidID()first.SteamAuthHandler::validate()'s OpenID-claimed-IDregex to anything OTHER than
7\d{16}+with theDmodifier.Second-round review (2f1fe61 + da583d3 + fccb177 + 6a018c4)
A second adversarial review surfaced four gaps the first-round
fixes didn't fully close. All four landed in this PR.
Finding 1 — Handler regex drift on the
Dmodifier (2f1fe61e)The first-round fix kept hand-rolled
preg_matchliterals at eachhandler call site (admins.php / bans.php / comms.php) as "defence in
depth". But the three copies subtly DIFFERED from the library's
ID_PATTERNS(and from each other) on theDmodifier — withoutit,
STEAM_0:0:1\nmatches the handler's^…$anchors (because$accepts end-of-string OR just-before-final-
\nin the default mode)and then fails the library's
SteamID::isValidID()(which DOES carryDpost-#1423 follow-up #1), triggeringSteamID::toSteam2()tothrow, which the dispatcher's
Throwablefallback wraps as a generic500 envelope. Exactly the bug class #1420 was supposed to close.
Lifted to a single library constant
SteamID::HANDLER_STRICT_REGEXwith paired tests (
SteamIDValidationTest::testHandlerStrictRegex*)and a source-of-truth pin
(
SteamIDValidationOrderTest::testJsonHandlersUseSingleSourceOfTruthRegex).Finding 2 — IP-type ban
:authiddivergence (2f1fe61e)The first-round nit (
82e8c3d2) "canonicalised valid Steam IDs onIP-type bans to match pre-tighter behaviour". The pre-tighter
behaviour was itself a bug: the page handler wrote whatever the user
typed in the Steam ID input into
:authidregardless of the banbeing IP-typed. The nit fix preserved the canonical-on-valid case
but failed to suppress the raw-on-invalid case —
garbageon anIP-type ban still landed in
:authidverbatim.The JSON API handler (
api_bans_add) also disagreed with the pagehandler: api converted
$rawSteamfor every non-empty inputregardless of
$banType, sotype=1&steam=garbage&ip=1.2.3.4rantoSteam2('garbage')→ 500 envelope (the bug class #1420 wassupposed to close, surfacing on the IP-type branch the original
review didn't cover).
Both halves now converge on
:authid = ''for IP-type bans, matchingthe schema's
NOT NULL DEFAULT ''. The form-side input remainsvisible on bounce, but the DB write is divorced from it. Test:
BansTest::testAddIpTypeAlwaysWritesEmptyAuthid(three subtestscovering valid Steam-shape, garbage, and trailing-newline bypass).
Finding 3 — Unvalidated input to
SteamID::compare()in kickit/blockit (da583d31)api_kickit_kick_playerandapi_blockit_block_playeriterate theA2S
GetPlayerslist and callSteamID::compare($a, $b)on theoperator-supplied
checkparameter for each returned player. Pre-fixthe operator-controlled
checkwent straight intocompare()→resolveInputID()→ throws on any malformed shape → dispatcher'sThrowablefallback → generic 500. Same bug class #1420 wassupposed to close, on a different handler pair the first-round
review didn't audit.
Fix: pre-validate via
SteamID::isValidID()(Steam arm) orfilter_var($check, FILTER_VALIDATE_IP)(kickit's IP arm) andreturn the structured
status: 'not_found'envelope the handlersalready emit for "no matching player". Tests:
KickitTest::testKickPlayerReturnsNotFoundForMalformedSteamId,testKickPlayerReturnsNotFoundForMalformedIp, and the matchingBlockitTest::testBlockPlayerReturnsNotFoundForMalformedSteamId.Finding 4 —
SteamAuthHandlerOpenID regex + install wizardDmodifier (fccb177b)SteamAuthHandler::validate()'s extract regex was7[0-9]{15,25}+— wider than reality (Steam in practice always returns exactly
17-digit Steam64s for individual accounts, and the library's
ID_PATTERNScarries^\d{17}$D). Pre-fix a 16-digit OR 18-25-digitOpenID claim slipped past
validate()but then failedSteamID::toSteam2()incheck(); the exception escaped theconstructor's call site unhandled (LightOpenID's mid-flow redirect
leaves no chrome to catch it), so the operator landed on a 500
mid-Steam-login round-trip — silent failure mode, no toast.
Tightened to
7\d{16}+with theDmodifier so the two gates agreebyte-for-byte. Added a
SteamID::isValidID()belt-and-suspendersgate in
check()so any future drift on either side surfaces as aclean redirect-to-login-failed.
web/install/pages/page.5.php(the install wizard's initial-adminform) carried its own regex without the
Dmodifier. Tightened to/^STEAM_[01]:[01]:[0-9]+$/Dso the wizard rejects newline-bypassedinput at the same gate the panel runtime does. Tests:
SteamIDValidationTest::testSteamAuthHandlerOpenIdRegexAcceptsOnly17DigitsStartingWith7and
testInstallWizardSteamIdRegexCarriesDModifier.Finding 5 — Handler trim-bypass test gaps (
6a018c44)The first-round handler tests covered trailing-newline cases
(
"STEAM_0:0:1\n"etc.), but the_register.phpdispatchertrim()s string params upstream of the handler — those test caseswere quietly redundant. Replaced with shapes
trim()does NOTdefend against: mid-string newlines (
"STEAM_0:0:1\nGARBAGE") andtrailing non-breaking spaces (
"STEAM_0:0:1\xC2\xA0", U+00A0 —trimonly handles ASCII whitespace). Library-side coverage in
SteamIDValidationTest::testHandlerStrictRegexRejectsNewlineBypassremains (the library doesn't
trim, so the newline-bypass teststhere are non-redundant).
Test plan
./sbpp.sh phpstan— 241/241 OK./sbpp.sh test— 856 tests, 3252 assertions OK./sbpp.sh composer api-contract— no diff (no handler signatures changed)./sbpp.sh ts-check— cleanCI=1 ./sbpp.sh e2e --grep 'comms-add|admin-edit-comms|admin-edit-ban|submit|protest|admins-add'— 48 passed (matches CI workers=1; the second-round parallel-mode run hit 12 failures that all surfaced asforbidden / No accessper the documentedFixture::truncateAndReseedrace, gone underworkers: 1)Out of scope / future work
:authidstorage off the:prefix_banstable into aseparate
:prefix_bans_authidslink table so the schema cancarry a NOT NULL FK without the IP-type-ban shim. Real schema
cleanup; out of scope for Comms block steamID validation - No notification on invalid steamID #1420.
page_admin_comms_add.tplandpage_admin_bans_add.tpl—deferred per the analysis above; will land if the two forms
converge further on validation strategy.
Fixes #1420.