Skip to content

Fix #1314: rename reused :sid placeholder in admin.srvadmins.php - #1328

Merged
rumblefrog merged 1 commit into
mainfrom
fix/issue-1314-srvadmins-pdo-param
May 10, 2026
Merged

rumblefrog merged 1 commit into
mainfrom
fix/issue-1314-srvadmins-pdo-param

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Fixes #1314.

Summary

The admin-list SELECT in pages/admin.srvadmins.php mentioned the named placeholder :sid twice (the outer WHERE server_id = :sid and the inner subquery's WHERE server_id = :sid) but called bind(':sid', …) only ONCE. Under emulated prepares (PDO's pre-#1124 default) client-side substitution rewrote every :sid occurrence to the literal value before the SQL hit MariaDB, so the duplicate-name pattern Just Worked. After #1124 / #1167 flipped PDO::ATTR_EMULATE_PREPARES to false (so LIMIT '0','30' would stop tripping MariaDB strict mode), the MySQL driver started expanding each :name occurrence into its own positional ? slot — bind()-ing one occurrence leaves the others unbound and execute() raises SQLSTATE[HY093] Invalid parameter number. Result: every admin with ADMIN_LIST_SERVERS who clicked Server Management → Admins after upgrading to v2.0 hit a page-load-blocking PHP fatal with no UI workaround.

What changed

  • web/pages/admin.srvadmins.php — rename the inner subquery's placeholder to :sid_inner and bind both, lift (int) $_GET['id'] into a local $sid (now coalesced on null for PHP 9 forward-compat), and reuse it in the checkMultiplePlayers($sid, …) call. While here, also add the missing global $userbank; declaration the page was relying on core/header.php to import as a side effect — clears the corresponding Variable $userbank might not be defined baseline entry in the same diff.
  • web/tests/integration/SrvAdminsPdoParamTest.php — new file. Two methods:
    • testReusedNamedPlaceholderUnderNativePreparesIsRejected — issues a tiny SELECT 1 ... WHERE aid = :sid OR aid = :sid against Sbpp\Db\Database with one bind() and asserts it throws HY093. Pins the contract; also a regression guard if anyone re-flips EMULATE_PREPARES back to true.
    • testAdminSrvadminsPageRendersWithoutPdoException — process-isolated require of the page handler with ?id=0 asserts no PDOException escapes. Pre-fix this test fails at Database.php:101 with the exact stack from the issue trace.
  • AGENTS.md — adds the :name-binding rule under the "Database" convention, the matching "Anti-patterns" entry, and a "Where to find what" row pointing at the new test.
  • ARCHITECTURE.md — documents the two practical consequences of EMULATE_PREPARES => false for callers (binary-protocol numerics AND each :name occurrence expanding to its own slot) under the Database subsystem walkthrough.
  • web/phpstan-baseline.neon — drop the Variable $userbank might not be defined entry for pages/admin.srvadmins.php now that the page declares the global itself.

A regex scan of web/pages/, web/api/handlers/, web/includes/, web/install/, web/updater/, and web/tests/ for queries that reuse the same :name more than once found this :sid in admin.srvadmins.php to be the only true offender — every other "reuse" hit was the :prefix_<table> literal that Database::setPrefix() rewrites BEFORE prepare() (and thus is harmless).

Test plan

  • ./sbpp.sh phpstan — passes (228 files, 0 errors). The cleared baseline entry stays consistent.
  • ./sbpp.sh test — passes (405 tests, 1768 assertions, 0 errors / 0 failures; the one PHPUnit deprecation is a pre-existing GroupsTest doc-comment metadata note unrelated to this PR).
  • ./sbpp.sh test --filter=SrvAdminsPdoParam — both methods green.
  • Reverted the SQL fix locally (sed: :sid_inner → :sid + drop the bind(':sid_inner', …)); re-ran the new test class — testAdminSrvadminsPageRendersWithoutPdoException fails at exactly pages/admin.srvadmins.php:52 with PDOException: SQLSTATE[HY093]: Invalid parameter number, matching the issue's stack. Restored the fix; both methods green again.
  • ./sbpp.sh ts-check / ./sbpp.sh composer api-contract / ./sbpp.sh e2e — not run; this PR touches no JS, no API handlers, and no user-facing UI. The integration test covers the page render end-to-end.

The admin-list SELECT in `pages/admin.srvadmins.php` mentioned the
named placeholder `:sid` twice (one in the outer
`WHERE server_id = :sid`, one in the inner subquery's
`WHERE server_id = :sid`) but called `bind(':sid', …)` only ONCE.
Under emulated prepares (PDO's pre-#1124 default), client-side
substitution rewrote every `:sid` occurrence to the literal value
before the SQL hit MariaDB, so the duplicate-name pattern Just
Worked. After #1124 / #1167 flipped `PDO::ATTR_EMULATE_PREPARES`
to `false` (so `LIMIT '0','30'` would stop tripping MariaDB strict
mode), the MySQL driver started expanding each `:name` occurrence
into its own positional `?` slot in the prepared statement —
`bind()`-ing one occurrence leaves the others unbound and
`execute()` raises `SQLSTATE[HY093] Invalid parameter number`.
Result: every admin with `ADMIN_LIST_SERVERS` who clicked
**Server Management → Admins** after upgrading to v2.0 hit a
page-load-blocking PHP fatal with no UI workaround.

The fix renames the inner placeholder to `:sid_inner` so each
position is bound separately. While here, also:

  - Add the missing `global $userbank;` declaration. The page used
    `$userbank` without declaring it global; this worked in
    production because `core/header.php` runs first inside `build()`
    and imports it into the function scope, but it was a latent bug
    that broke the new test harness and the corresponding
    `Variable $userbank might not be defined` baseline entry was
    sitting in `phpstan-baseline.neon`. Drop the baseline entry now
    that the page declares the global itself, matching every other
    admin page.
  - Lift `(int) $_GET['id']` into a local `$sid` so the value isn't
    re-cast across the SELECT bind + the `checkMultiplePlayers` call,
    and coalesce on `null` so the read survives PHP 9's stricter
    null-into-scalar rules.

Regression coverage in `web/tests/integration/SrvAdminsPdoParamTest.php`
(two methods):

  - `testReusedNamedPlaceholderUnderNativePreparesIsRejected` — issues
    a tiny `SELECT 1 ... WHERE aid = :sid OR aid = :sid` against
    `Sbpp\Db\Database` with one `bind()` and asserts it throws
    `HY093`. Pins the contract; also a regression guard if anyone
    re-flips `EMULATE_PREPARES` back to `true`.
  - `testAdminSrvadminsPageRendersWithoutPdoException` — process-
    isolated `require` of the page handler with `?id=0` asserts no
    `PDOException` escapes. Pre-fix this test fails at
    `Database.php:101` with the exact stack from the issue trace.

Docs updated in the same PR per AGENTS.md "Keep the docs in sync":

  - `AGENTS.md` adds the `:name`-binding rule under the "Database"
    convention, the matching "Anti-patterns" entry, and a
    "Where to find what" row pointing at the new test.
  - `ARCHITECTURE.md` documents the two practical consequences of
    `EMULATE_PREPARES => false` for callers (binary-protocol numerics
    AND each `:name` occurrence expanding to its own slot) under the
    Database subsystem walkthrough.
@rumblefrog
rumblefrog added this pull request to the merge queue May 10, 2026
Merged via the queue into main with commit ffebced May 10, 2026
4 checks passed
@rumblefrog
rumblefrog deleted the fix/issue-1314-srvadmins-pdo-param branch May 10, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fatal PDOException in admin.srvadmins.php:38 — SQLSTATE[HY093] Invalid parameter number (:sid reused but bound once under native prepares)

1 participant