Skip to content

Commit ffebced

Browse files
authored
Fix #1314: rename reused :sid placeholder in admin.srvadmins.php (#1328)
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.
1 parent cd72312 commit ffebced

5 files changed

Lines changed: 252 additions & 10 deletions

File tree

‎AGENTS.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,26 @@ top-level `class Foo {}` in `web/includes/` (see "Anti-patterns").
357357
`Database::query()` rewrites the placeholder. Never inline the prefix.
358358
- Pattern: `query` → `bind` → `execute` / `single` / `resultset`.
359359
- ADOdb was fully removed (commit `b9c812b2`). **Do not reintroduce it.**
360+
- Each named placeholder (`:name`) inside one query needs as many
361+
`bind()` calls as occurrences. The panel runs PDO with
362+
`PDO::ATTR_EMULATE_PREPARES => false` (`Sbpp\Db\Database::__construct`
363+
— set at #1124 / motivated by #1167 so `LIMIT '0','30'` stops
364+
tripping MariaDB strict mode). Under native prepares the MySQL
365+
driver expands every `:name` occurrence into its own positional
366+
`?` slot in the prepared statement, so reusing `:sid` twice and
367+
`bind(':sid', …)` once leaves the second slot unbound and
368+
`execute()` raises `SQLSTATE[HY093] Invalid parameter number`
369+
(#1314). Pre-#1124 emulated prepares masked this by client-side
370+
string substitution at every occurrence. Either rename each
371+
occurrence (`:sid` + `:sid_inner`) and `bind()` each, or pass the
372+
values via the `resultset(['sid' => …, 'sid_inner' => …])` array
373+
shortcut — both shapes are equivalent. The `:prefix_` literal is
374+
not a real PDO placeholder; it's a substring that
375+
`Database::setPrefix()` replaces before `prepare()`, so reuse
376+
there is harmless. Regression guard:
377+
`web/tests/integration/SrvAdminsPdoParamTest.php` pins both the
378+
contract (single-bind on a reused name throws `HY093`) and the
379+
page-level fix (`admin.srvadmins.php` renders without raising).
360380

361381
### PHP 8.5 idioms (post-#1289 floor bump)
362382

@@ -1165,6 +1185,24 @@ audit (#1207) locked in. New CTAs:
11651185
- `utf8` (3-byte alias) for `DB_CHARSET` → always `utf8mb4`. 4-byte
11661186
sequences (emoji, some CJK) otherwise trip `Incorrect string value`
11671187
from the plugin's insert path (#1108, #765).
1188+
- Reusing the same `:name` PDO placeholder more than once inside a
1189+
query while calling `bind(':name', …)` only ONCE → the panel runs
1190+
PDO with `EMULATE_PREPARES => false` (default since #1124 / #1167's
1191+
`LIMIT '0','30'` MariaDB regression), so the MySQL driver expands
1192+
every `:name` occurrence into its own positional `?` slot in the
1193+
prepared statement. A single `bind()` leaves the others unbound
1194+
and `execute()` raises `SQLSTATE[HY093] Invalid parameter number`
1195+
(#1314 — `admin.srvadmins.php`'s `:sid` / `:sid` / `bind(':sid', …)`
1196+
shape, which Just Worked under emulated prepares pre-#1124 and
1197+
fataled on every page load post-#1124). Either rename each
1198+
occurrence (`:sid` + `:sid_inner`) and `bind()` each, or pass the
1199+
values via `resultset(['sid' => …, 'sid_inner' => …])`. The
1200+
`:prefix_` literal is rewritten by `Database::setPrefix()` before
1201+
`prepare()`, so reuse there is harmless and stays out of this
1202+
rule. Re-flipping `EMULATE_PREPARES` back to `true` to mask the
1203+
bug is a sibling anti-pattern — it would silently reintroduce the
1204+
`LIMIT '0','30'` trap (`page.banlist.php` / `page.commslist.php`
1205+
rejected by MariaDB strict mode). See "Database" under Conventions.
11681206
- Editing `install/includes/sql/data.sql` (or `struc.sql`) without a paired
11691207
`web/updater/data/<N>.php` → upgraded installs silently miss the change.
11701208
- WYSIWYG / "rich HTML" editors (TinyMCE, CKEditor, …) for fields stored
@@ -1424,6 +1462,7 @@ audit (#1207) locked in. New CTAs:
14241462
| API wire-format snapshots | `web/tests/api/__snapshots__/<topic>/<scenario>.json` |
14251463
| Action -> permission lock | `web/tests/api/PermissionMatrixTest.php` |
14261464
| Trap PHP 8.1 null-into-scalar deprecations at runtime (the bits PHPStan can't see) | `web/tests/integration/Php82DeprecationsTest.php` (#1273) — process-isolated render harness with a stub Smarty + `set_error_handler` that promotes `E_DEPRECATED` / `E_USER_DEPRECATED` to `\ErrorException`. Mirrors the LostPasswordChromeTest stub-Smarty pattern; each test method runs in a separate process because the page handlers declare top-level helpers (`setPostKey()` etc.) that PHP can't redeclare in one process. Add a marquee route here whenever a new high-traffic page handler ships, especially if it reads nullable `:prefix_*` columns or `$_POST` / `$_GET` lookups. |
1465+
| Pin the "every `:name` PDO placeholder needs as many `bind()` calls as occurrences" contract under native prepares | `web/tests/integration/SrvAdminsPdoParamTest.php` (#1314) — 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`; this is the contract pin (also a regression guard if anyone re-flips `EMULATE_PREPARES` back to `true`). `testAdminSrvadminsPageRendersWithoutPdoException` is the page-level regression guard for the actual #1314 fatal — process-isolated `require` of `pages/admin.srvadmins.php` with `?id=0` asserting no `PDOException` escapes. Mirrors the Php82DeprecationsTest stub-Smarty + process-isolation shape. |
14271466
| Add an E2E spec | `web/tests/e2e/specs/<smoke|flows|a11y|responsive>/...` + `web/tests/e2e/pages/...` |
14281467
| Add a route to the screenshot gallery | `web/tests/e2e/specs/_screenshots.spec.ts` (`ROUTES` array) |
14291468
| Tweak mobile (<=768px) chrome layout | `web/themes/default/css/theme.css` — see the `#1207` `@media (max-width: 768px)` blocks for the canonical shapes (icon-only topbar search, full-width drawer + scroll lock). Sub-paged admin routes (servers / mods / groups / comms / settings / admins / bans) use the `<details open>` accordion in the `#1259` `@media (min-width: 1024px)` block (sidebar inline at `<1024px`, sticky 14rem rail at `>=1024px`); see "Sub-paged admin routes" in Conventions. |

‎ARCHITECTURE.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,25 @@ A thin PDO wrapper. Two things to know:
340340
$row = $GLOBALS['PDO']->single(); // or ->resultset() / ->execute()
341341
```
342342

343+
- The constructor sets `PDO::ATTR_EMULATE_PREPARES => false` (added at
344+
#1124 / motivated by #1167's `LIMIT '0','30'` MariaDB strict-mode
345+
regression). Two practical consequences for callers: numeric values
346+
go through MySQL's binary protocol with proper type metadata
347+
(`LIMIT ?,?` works as expected), AND every named placeholder
348+
occurrence is expanded into its own positional `?` slot in the
349+
prepared statement — so a query that mentions `:sid` twice needs
350+
TWO `bind(':sid', …)` calls (or distinct names like `:sid` +
351+
`:sid_inner`), otherwise `execute()` raises `SQLSTATE[HY093]
352+
Invalid parameter number`. Pre-#1124 emulated prepares masked
353+
the duplicate-name pattern by client-side string substitution at
354+
every occurrence. The contract is pinned by
355+
`web/tests/integration/SrvAdminsPdoParamTest.php` (the regression
356+
guard for #1314, where `pages/admin.srvadmins.php` reused `:sid`
357+
twice and bound once — page-load-blocking fatal for every admin
358+
with `ADMIN_LIST_SERVERS` after upgrading to v2.0). The `:prefix_`
359+
placeholder is rewritten by `setPrefix()` BEFORE `prepare()`, so
360+
`:prefix_admins` reuse is harmless and stays out of this rule.
361+
343362
The legacy ADOdb layer was fully removed in commit `b9c812b2`; do not
344363
reintroduce it. PHPStan + `staabm/phpstan-dba` introspect the live
345364
schema (rendered from `install/includes/sql/struc.sql`) and type-check

‎web/pages/admin.srvadmins.php‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,47 @@
1717
Page: <http://www.sourcebans.net/> - <http://www.gameconnect.net/>
1818
*************************************************************************/
1919

20-
global $theme;
20+
global $userbank, $theme;
2121

2222
new AdminTabs([], $userbank, $theme);
2323

2424
$admsteam = [];
2525
$admins = [];
2626

27+
// Issue #1314: under native prepares (`PDO::ATTR_EMULATE_PREPARES =>
28+
// false`, the panel's default since #1124 / motivated by #1167's
29+
// `LIMIT '0','30'` MariaDB regression), the MySQL driver expands every
30+
// `:name` occurrence into its own positional `?` slot in the prepared
31+
// statement. A name reused inside one query therefore needs as many
32+
// bind() calls as occurrences — `bind(':sid', …)` ONCE on a query
33+
// that mentions `:sid` twice leaves the second slot unbound and
34+
// `execute()` raises `SQLSTATE[HY093] Invalid parameter number`. Pre
35+
// #1124 the duplicate-name pattern was masked by emulated prepares
36+
// substituting the literal value client-side at every occurrence.
37+
// The inner subquery's placeholder is renamed to `:sid_inner` so each
38+
// position is bound separately. See AGENTS.md Anti-patterns ("Reusing
39+
// a `:name` placeholder ...").
40+
$sid = (int) ($_GET['id'] ?? 0);
2741
$GLOBALS['PDO']->query("SELECT authid, user
2842
FROM `:prefix_admins_servers_groups` AS asg
2943
LEFT JOIN `:prefix_admins` AS a ON a.aid = asg.admin_id
3044
WHERE (server_id = :sid OR srv_group_id = ANY
3145
(
3246
SELECT group_id
3347
FROM `:prefix_servers_groups`
34-
WHERE server_id = :sid)
48+
WHERE server_id = :sid_inner)
3549
)
3650
GROUP BY aid, authid, srv_password, srv_group, srv_flags, user ");
37-
$GLOBALS['PDO']->bind(':sid', (int) $_GET['id']);
51+
$GLOBALS['PDO']->bind(':sid', $sid);
52+
$GLOBALS['PDO']->bind(':sid_inner', $sid);
3853
$srv_admins = $GLOBALS['PDO']->resultset();
3954
$i = 0;
4055
foreach ($srv_admins as $admin) {
4156
if ($admin['authid'] !== null) {
4257
$admsteam[] = $admin['authid'];
4358
}
4459
}
45-
if (count($admsteam) > 0 && $serverdata = checkMultiplePlayers((int) $_GET['id'], $admsteam)) {
60+
if (count($admsteam) > 0 && $serverdata = checkMultiplePlayers($sid, $admsteam)) {
4661
$noproblem = true;
4762
}
4863
foreach ($srv_admins as $admin) {

‎web/phpstan-baseline.neon‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,6 @@ parameters:
312312
count: 7
313313
path: pages/admin.settings.php
314314

315-
-
316-
message: '#^Variable \$userbank might not be defined\.$#'
317-
identifier: variable.undefined
318-
count: 1
319-
path: pages/admin.srvadmins.php
320-
321315
-
322316
message: '#^Variable \$title might not be defined\.$#'
323317
identifier: variable.undefined
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sbpp\Tests\Integration;
6+
7+
use PDOException;
8+
use PHPUnit\Framework\Attributes\PreserveGlobalState;
9+
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
10+
use Sbpp\Db\Database;
11+
use Sbpp\Tests\ApiTestCase;
12+
use Smarty\Smarty;
13+
14+
/**
15+
* Issue #1314: `web/pages/admin.srvadmins.php` issued a query that
16+
* mentioned the named placeholder `:sid` twice (one in the outer
17+
* `WHERE server_id = :sid`, one in the inner subquery's
18+
* `WHERE server_id = :sid`) but called `bind(':sid', ...)` only
19+
* ONCE. Under emulated prepares (PDO's pre-#1124 default), client-side
20+
* substitution rewrote every `:sid` occurrence to the literal value
21+
* before the SQL hit MariaDB, so the duplicate-name pattern Just
22+
* Worked. After #1124 / #1167 flipped `PDO::ATTR_EMULATE_PREPARES`
23+
* to `false` (so `LIMIT '0','30'` would stop tripping MariaDB strict
24+
* mode), the MySQL driver started expanding each `:name` occurrence
25+
* into its own positional `?` slot in the prepared statement —
26+
* `bind()`-ing one occurrence leaves the others unbound and
27+
* `execute()` raises `SQLSTATE[HY093] Invalid parameter number`.
28+
*
29+
* The fix renames the inner placeholder to `:sid_inner` and binds
30+
* both. The two test methods here pin both halves of the contract:
31+
*
32+
* 1. {@see testReusedNamedPlaceholderUnderNativePreparesIsRejected}
33+
* — a small standalone query against `Sbpp\Db\Database` that
34+
* reproduces the offending shape (one `bind()` for two `:sid`
35+
* slots) and asserts it throws `HY093`. This documents WHY the
36+
* rename in `admin.srvadmins.php` matters; if a future
37+
* contributor "tidies up" the SQL back into `:sid` + a single
38+
* bind, this test fails before the page does in production.
39+
* It also doubles as a regression guard if anyone re-flips
40+
* `EMULATE_PREPARES` back to `true` (which would silently
41+
* reintroduce the original masking).
42+
*
43+
* 2. {@see testAdminSrvadminsPageRendersWithoutPdoException} — the
44+
* end-to-end shape: requires the page handler with `$_GET['id']`
45+
* set, asserts the SELECT prepares + executes cleanly through
46+
* the global `$PDO` wrapper. Mirrors the
47+
* Php82DeprecationsTest stub-Smarty + process-isolation pattern
48+
* so the page handler's top-level helper declarations don't
49+
* collide across cases and `$_GET` / `$_SESSION` state stays
50+
* clean per case.
51+
*/
52+
final class SrvAdminsPdoParamTest extends ApiTestCase
53+
{
54+
/**
55+
* Capture-only Smarty stub. The page handler calls
56+
* `$theme->assign(...)` and `$theme->display(...)`; we don't care
57+
* about the rendered output, only that the PHP code path
58+
* (including the SELECT under test) runs without raising.
59+
* Mirrors `Php82DeprecationsTest::makeStubTheme()`.
60+
*/
61+
private function makeStubTheme(): Smarty
62+
{
63+
return new class extends Smarty {
64+
/** @phpstan-ignore method.childParameterType */
65+
public function assign($tpl_var, $value = null, $nocache = false, $scope = null)
66+
{
67+
return $this;
68+
}
69+
70+
public function display($template = null, $cache_id = null, $compile_id = null)
71+
{
72+
return '';
73+
}
74+
};
75+
}
76+
77+
/**
78+
* Wire the globals every page handler under `web/pages/*` reads
79+
* straight from `$GLOBALS` (`$theme`, `$userbank`, `$PDO`). The
80+
* production `init.php` does this on every real request; for the
81+
* test we mirror what `Php82DeprecationsTest::bootRenderHarness()`
82+
* sets up so the require'd page handler finds the same names.
83+
*/
84+
private function bootRenderHarness(): Smarty
85+
{
86+
$theme = $this->makeStubTheme();
87+
$GLOBALS['theme'] = $theme;
88+
$GLOBALS['userbank'] = $GLOBALS['userbank'] ?? new \CUserManager(null);
89+
$GLOBALS['username'] = $GLOBALS['username'] ?? 'tester';
90+
91+
return $theme;
92+
}
93+
94+
/**
95+
* Standalone contract pin: under the panel's production PDO
96+
* options (`EMULATE_PREPARES => false`, set in
97+
* `Sbpp\Db\Database::__construct`), reusing the same `:name`
98+
* placeholder more than once with a SINGLE `bind()` call leaves
99+
* the second slot unbound and `PDOStatement::execute()` raises
100+
* `SQLSTATE[HY093] Invalid parameter number`.
101+
*
102+
* Constructing a fresh `Database` (rather than reaching for
103+
* `$GLOBALS['PDO']`) keeps the test self-contained — and proves
104+
* the assertion is about `Database`'s configuration (the
105+
* `EMULATE_PREPARES => false` option in the constructor), not
106+
* about whatever stale state another test might have left on
107+
* the global wrapper.
108+
*
109+
* The `:prefix_admins` table is one of the tables `Fixture` seeds
110+
* by default (the admin row from `seedAdmin`), so the SELECT
111+
* itself has something to query — the assertion is purely on
112+
* the parameter-binding mismatch, not on row presence.
113+
*/
114+
public function testReusedNamedPlaceholderUnderNativePreparesIsRejected(): void
115+
{
116+
$db = new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET);
117+
118+
// Two `:sid` occurrences, one `bind()` call. This is the
119+
// exact failure mode `admin.srvadmins.php` exhibited before
120+
// the fix.
121+
$db->query('SELECT 1 FROM `:prefix_admins` WHERE aid = :sid OR aid = :sid LIMIT 1');
122+
$db->bind(':sid', 1);
123+
124+
$this->expectException(PDOException::class);
125+
$this->expectExceptionMessageMatches('/HY093|Invalid parameter number/i');
126+
127+
$db->resultset();
128+
}
129+
130+
/**
131+
* The headline regression: hitting `?p=admin&c=servers&o=admincheck&id=<sid>`
132+
* pre-fix raised `PDOException` from the SELECT in
133+
* `admin.srvadmins.php` (line 38 in the issue trace). The test
134+
* requires the page file with `$_GET['id']` set so the same
135+
* code path runs, and asserts no exception escapes.
136+
*
137+
* Process-isolation matches `Php82DeprecationsTest`: the page
138+
* handler defines top-level helpers / variables PHP can't
139+
* redeclare in a single process across multiple test cases, and
140+
* we want a clean `$_GET` / `$_SESSION` / `$GLOBALS` per case.
141+
*
142+
* `$_GET['id']` is set to `0` deliberately — the seeded test DB
143+
* has no `:prefix_admins_servers_groups` rows, so any server
144+
* id (real or sentinel `0`) returns an empty result set. The
145+
* regression is on the SELECT's PREPARE + EXECUTE step, which
146+
* runs whether the result set is empty or not. Setting `0`
147+
* also avoids needing to seed a server row just for the test.
148+
*
149+
* `checkMultiplePlayers($sid, ...)` is gated on
150+
* `count($admsteam) > 0`; with an empty result set the call is
151+
* skipped, so we don't risk a real RCON socket open against an
152+
* arbitrary IP.
153+
*/
154+
#[RunInSeparateProcess]
155+
#[PreserveGlobalState(false)]
156+
public function testAdminSrvadminsPageRendersWithoutPdoException(): void
157+
{
158+
$this->loginAsAdmin();
159+
$this->bootRenderHarness();
160+
161+
$_SESSION = [];
162+
$_GET = ['p' => 'admin', 'c' => 'servers', 'o' => 'admincheck', 'id' => '0'];
163+
164+
ob_start();
165+
try {
166+
require ROOT . 'pages/admin.srvadmins.php';
167+
} finally {
168+
ob_end_clean();
169+
}
170+
171+
$this->assertTrue(true,
172+
'admin.srvadmins.php must prepare + execute its admin-list SELECT '
173+
. 'without raising PDOException HY093 (issue #1314).');
174+
}
175+
}

0 commit comments

Comments
 (0)