Skip to content

fix(steamid): surface validation errors across comms / bans / admin add + edit forms (#1420)#1423

Merged
rumblefrog merged 9 commits into
mainfrom
fix/comms-block-steamid-validation-1420
May 20, 2026
Merged

fix(steamid): surface validation errors across comms / bans / admin add + edit forms (#1420)#1423
rumblefrog merged 9 commits into
mainfrom
fix/comms-block-steamid-validation-1420

Conversation

@rumblefrog

@rumblefrog rumblefrog commented May 19, 2026

Copy link
Copy Markdown
Member

Summary

Eight commits — the implementer's original fix (e3a8106d), an
adversarial-review follow-up (821e81f4) closing a server-side
bypass 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 review
missed (per-handler regex drift on the D modifier, IP-type ban
:authid storage divergence, unvalidated input to SteamID::compare()
in kickit/blockit, an overly permissive OpenID regex in
SteamAuthHandler, and a missing D modifier in the install
wizard'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.show against the v1.x
#dialog-placement chrome shell — DOM ids the v2.0 theme doesn't
render anywhere. Net effect: every error branch silently no-op'd
client-side, and the server-side SteamID::toSteam2() call threw a
generic \Exception that the dispatcher's catch-all wrapped as a
500 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::isValidID
as 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:

  • Lifted the four accepted shapes into a shared SteamID::ID_PATTERNS
    constant. Both isValidID() and resolveInputID() now consume
    the 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 — Steam64
  • The D modifier closes the STEAM_0:0:1\n newline-bypass
    sibling (without D, PHP's $ matches end-of-string OR a final
    \n).
  • Verified the library is in-tree under web/includes/SteamID/ (NOT
    vendor/) — the project owns it, the "third-party-vendored"
    framing in the original review was incorrect.
  • Audited every caller (rg 'SteamID::' web/ game/ docker/):
    • Install wizard, admin.edit.* page handlers, page.submit.php,
      page.protest.php, page.banlist.php, page.commslist.php,
      SteamAuthHandler.php — all unaffected (their happy paths
      produce inputs the strict regex still accepts).
    • SourceMod plugins extract SteamIDs from GetClientAuthId and
      bind directly to MySQL queries; the PHP library is panel-side
      only.
    • The three JSON *_add handlers KEEP their per-handler preg_match
      as 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).
  • Added web/tests/integration/SteamIDValidationTest.php (104
    tests / 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_PATTERNS introspection and the
    PHP-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 handler
unhandled, the chrome's PageDie() never fired, and the user
got 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-convert
    ladder, error into $validationErrors['steam'], raw input
    preserved on bounce.
  • web/pages/admin.edit.comms.php — same ladder, error into
    $errorFields[] = ['steam.msg', '…'].
  • web/pages/admin.edit.admindetails.php — same ladder, error
    into $validationErrors['steam']; the $resolvedSteam /
    $steamIsValidShape distinction surfaces empty vs invalid as
    distinct messages.
  • web/pages/page.submit.php — already gated on isValidID();
    the library tightening closes the bypass without a handler edit.
    The legacy STEAM_0: empty-sentinel pre-fill (which would have
    failed the new template pattern="…" check) was dropped.
  • web/pages/admin.bans.php's importBans branch — discovered
    during the audit, same bug class. A single malformed banid …
    line in the operator's uploaded banned_user.cfg aborted the
    entire 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):

  • All four form templates carry the strict
    pattern="STEAM_[01]:[01]:\d+|\[U:1:\d+\]|\d{17}" + actionable
    title="…" attributes on the Steam ID input.
  • page_admin_edit_admins_details.tpl + page_admin_edit_comms.tpl
    also carry required + aria-required="true" (every admin row
    must carry an :authid; every comm-block must target a
    SteamID).
  • page_admin_edit_ban.tpl + page_submitban.tpl are intentionally
    NOT required — both legitimately accept an empty Steam ID
    when the operator provides an IP instead.
  • page_submitban.tpl dropped novalidate so native validation
    actually 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.tpl
and page_admin_bans_add.tpl) could be consolidated into a shared
web/scripts/admin-add-form.js helper. After diffing the two
IIFEs in detail:

Shared (~35 lines, ~25% of each IIFE):

  • The api() / actions() / $id() / setMsg() / toast() /
    setBusy() helper functions are identical byte-for-byte
    (graceful-degradation wrappers for when theme.js is stripped
    by a third-party theme).

Divergent (~75% of each IIFE, fundamentally different logic):

  • Validation strategy: comms-add uses native form.checkValidity()
    (every input is unconditionally required); bans-add uses an
    ad-hoc validate(type) function for type-conditional steam-vs-IP
    validation (native HTML can't express the type-conditional
    gate).
  • Field harvest: comms-add: 5 fields; bans-add: 9 fields
    (adds ip, dfile, dname, fromsub).
  • Success path: comms-add builds an iframe to
    admin.blockit.php to fire RCON; bans-add calls
    ShowKickBox/TabToReload + resets the form in place.
  • Error routing: comms-add routes error.field to specific
    *.msg divs (steam.msg / nick.msg / reason.msg); bans-add
    just toasts.
  • Post-success behavior: comms-add keeps button busy +
    page-reloads; bans-add re-enables button + form.reset().

Why deferred:

  1. The 75% divergence is exactly where the logic lives. Lifting
    the 25% boilerplate would force a parameterised helper API
    (validate callback, harvest callback, onSuccess callback,
    onErrorField map) that would leak the divergence through
    the helper's parameter shapes — replacing 35 lines of
    duplication with 50+ lines of indirection that's harder to
    read than the inlined IIFEs.
  2. AGENTS.md's "Loading state on action buttons" convention
    explicitly says inline page-tail scripts should define
    setBusy(btn, busy) as a local wrapper that delegates to
    window.SBPP.setBusy when present and falls back to plain
    disabled otherwise. The fallback is what keeps the
    double-click gate working on third-party themes that strip
    theme.js. Lifting the wrapper to a shared file at
    web/scripts/admin-add-form.js would fight that contract
    (a third-party theme stripping theme.js would also strip
    the new shared script).
  3. Both forms are in active flux (just edited in this PR for
    validation); locking them into a shared API now would make
    future per-form tweaks more painful.
  4. Zero user-visible benefit. The reviewer explicitly flagged
    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

  • Rewrote the "SteamID inputs" Conventions block to reflect the
    library tightening (the preg_match is now true
    defence-in-depth alongside SteamID::isValidID()).
  • Added a "page-handler form-POST surfaces" subsection
    documenting the validate-then-convert ladder shape across
    admin.edit.* / page.submit.php / importBans.
  • Six Anti-patterns entries added across both review rounds:
    • Loosening SteamID::ID_PATTERNS (back to substring matcher
      / [0|1] / \d*).
    • Removing the per-handler preg_match "now that the library
      is strict too".
    • Calling SteamID::toSteam2() before SteamID::isValidID()
      on a page handler's raw POST input (the convert-before-
      validate bug class).
    • Wrapping toSteam2() in try/catch (\Exception) as a
      substitute for the upstream isValidID() gate.
    • Hand-rolling the strict SteamID-shape regex literal at the
      per-handler preg_match call site (now sourced from
      SteamID::HANDLER_STRICT_REGEX).
    • Storing the operator-typed Steam ID in :prefix_bans.authid
      on an IP-type ban (must be '' per the schema's
      NOT NULL DEFAULT '').
    • Calling SteamID::compare(…) with operator-controlled input
      that hasn't been gated through SteamID::isValidID() first.
    • Tightening SteamAuthHandler::validate()'s OpenID-claimed-ID
      regex to anything OTHER than 7\d{16}+ with the D modifier.

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 D modifier (2f1fe61e)

The first-round fix kept hand-rolled preg_match literals at each
handler 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 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. Exactly the bug class #1420 was supposed to close.

Lifted to a single library constant SteamID::HANDLER_STRICT_REGEX
with paired tests (SteamIDValidationTest::testHandlerStrictRegex*)
and a source-of-truth pin
(SteamIDValidationOrderTest::testJsonHandlersUseSingleSourceOfTruthRegex).

Finding 2 — IP-type ban :authid divergence (2f1fe61e)

The first-round nit (82e8c3d2) "canonicalised valid Steam IDs on
IP-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 :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 on an
IP-type ban still landed in :authid verbatim.

The JSON API handler (api_bans_add) also disagreed with the page
handler: api converted $rawSteam for every non-empty input
regardless of $banType, so type=1&steam=garbage&ip=1.2.3.4 ran
toSteam2('garbage') → 500 envelope (the bug class #1420 was
supposed to close, surfacing on the IP-type branch the original
review didn't cover).

Both halves now converge on :authid = '' for IP-type bans, matching
the schema's NOT NULL DEFAULT ''. The form-side input remains
visible on bounce, but the DB write is divorced from it. Test:
BansTest::testAddIpTypeAlwaysWritesEmptyAuthid (three subtests
covering valid Steam-shape, garbage, and trailing-newline bypass).

Finding 3 — Unvalidated input to SteamID::compare() in kickit/blockit (da583d31)

api_kickit_kick_player and api_blockit_block_player iterate the
A2S GetPlayers list and call SteamID::compare($a, $b) on the
operator-supplied check parameter for each returned player. Pre-fix
the operator-controlled check went straight into compare()
resolveInputID() → throws on any malformed shape → dispatcher's
Throwable fallback → generic 500. Same bug class #1420 was
supposed to close, on a different handler pair the first-round
review didn't audit.

Fix: pre-validate via SteamID::isValidID() (Steam arm) or
filter_var($check, FILTER_VALIDATE_IP) (kickit's IP arm) and
return the structured status: 'not_found' envelope the handlers
already emit for "no matching player". Tests:
KickitTest::testKickPlayerReturnsNotFoundForMalformedSteamId,
testKickPlayerReturnsNotFoundForMalformedIp, and the matching
BlockitTest::testBlockPlayerReturnsNotFoundForMalformedSteamId.

Finding 4 — SteamAuthHandler OpenID regex + install wizard D modifier (fccb177b)

SteamAuthHandler::validate()'s extract regex was 7[0-9]{15,25}+
— 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). Pre-fix a 16-digit OR 18-25-digit
OpenID claim slipped past validate() but then failed
SteamID::toSteam2() in check(); 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.

Tightened to 7\d{16}+ with the D modifier so the two gates agree
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.

web/install/pages/page.5.php (the install wizard's initial-admin
form) carried its own regex without the D modifier. Tightened to
/^STEAM_[01]:[01]:[0-9]+$/D so the wizard rejects newline-bypassed
input at the same gate the panel runtime does. Tests:
SteamIDValidationTest::testSteamAuthHandlerOpenIdRegexAcceptsOnly17DigitsStartingWith7
and 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.php dispatcher
trim()s string params upstream of the handler — those test cases
were quietly redundant. Replaced with shapes trim() does NOT
defend against: mid-string newlines ("STEAM_0:0:1\nGARBAGE") and
trailing non-breaking spaces ("STEAM_0:0:1\xC2\xA0", U+00A0 — trim
only handles ASCII whitespace). Library-side coverage in
SteamIDValidationTest::testHandlerStrictRegexRejectsNewlineBypass
remains (the library doesn't trim, so the newline-bypass tests
there 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 — clean
  • CI=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 as forbidden / No access per the documented Fixture::truncateAndReseed race, gone under workers: 1)

Out of scope / future work

  • Splitting :authid storage off the :prefix_bans table into a
    separate :prefix_bans_authids link table so the schema can
    carry 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.
  • Lifting the shared 25%-boilerplate IIFE helper from
    page_admin_comms_add.tpl and page_admin_bans_add.tpl
    deferred per the analysis above; will land if the two forms
    converge further on validation strategy.

Fixes #1420.

…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
@rumblefrog rumblefrog changed the title fix(comms): surface SteamID validation errors on comms / bans / admin add forms (#1420) fix(steamid): surface validation errors across comms / bans / admin add + edit forms (#1420) May 19, 2026
#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`.
@rumblefrog
rumblefrog added this pull request to the merge queue May 20, 2026
Merged via the queue into main with commit 3c5c606 May 20, 2026
8 checks passed
@rumblefrog
rumblefrog deleted the fix/comms-block-steamid-validation-1420 branch May 20, 2026 03:20
@github-actions github-actions Bot locked and limited conversation to collaborators May 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Comms block steamID validation - No notification on invalid steamID

1 participant