Bugfix Issue 1731 ReqMgrSystemEdit Shim Array Request E_WARNING - sebiboga/testlink-upgraded GitHub Wiki
Issue #1731 — the reqMgrSystemEdit.php 302 shim cast $_REQUEST without a shape check: ?doAction[]=x wrote an E_WARNING and an ERROR row into events on every request
Issue: #1731
Commits: 844bba827 (fix) + cb7c5bf01 (regression suite)
Branch: fix/issue-1731 (pushed; the CI merge-prs.yml lands it)
Status: VERIFIED-FIXED — regression suite 14/14 PASS (Suite "Regression — Issue #1731", cases 53-66)
Scope of this document: the shim half of #1731. The BFF half was already fixed in ef3331fd0 and is documented in Modernized-ReqMgrSystemEdit.md.
Sibling still open: #1732 — the identical cast in lib/testcases/listTestCases.php.
Symptom
One crafted GET against the legacy-URL redirect shim wrote two events rows, repeatably, with
no cap:
log_level 2 | E_WARNING
Array to string conversion - in .../lib/reqmgrsystems/reqMgrSystemEdit.php - Line 66
log_level 1 | reqMgrSystemEdit.php shim: unknown doAction "Array" - refusing to guess a modern target (Refs #1727).
The HTTP response itself is a perfectly normal 302 Found to the list screen, so from the
outside the request is indistinguishable from the intended "unknown doAction → list screen"
path. 5 requests produced 10 rows.
The approach, and why this one
Root cause
lib/reqmgrsystems/reqMgrSystemEdit.php was rewritten in ef3331fd0 ("harden the editor BFF and
the shim") from a full legacy page into a session-guarded 302 redirector. The BFF's parameter
reads were hardened in that same commit with is_scalar() (api/reqmgrsystemedit/index.php:69,95-100,118-123).
The shim's own reads were not — they were carried over verbatim from 1.9.20:
$doAction = isset($_REQUEST['doAction']) ? trim((string)$_REQUEST['doAction']) : 'create';
$id = isset($_REQUEST['id']) ? intval($_REQUEST['id']) : 0;
...
$v = isset($_REQUEST[$k]) ? intval($_REQUEST[$k]) : 0; // $k in {tproject_id, tplan_id}
isset() returns true for ?doAction[]=x — PHP fills $_REQUEST['doAction'] with an array —
and is_scalar() is never consulted. Two distinct failures follow:
- Loud.
(string)arrayraisesE_WARNING Array to string conversion.logger.class.phpinstallswatchPHPErrorsas the error handler, so that warning is persisted as alog_level=2row. The array is then coerced to the literal string"Array", which matches noswitchcase, falls intodefault:, and fires the shim's owntLog(..., 'ERROR')— the second, worse row. So the corruption and the log-storm compound: 2 rows, not 1. - Silent.
intval(array)is warning-free in PHP 8 and returns1. So?id[]=1&doAction=editsilently addressed system 1, and?tproject_id[]=1silently dropped the user into test project 1 — with no warning and therefore nothing at all inevents. Fixing only the loud path would have left this intact.
Reachability — the part that makes it a real defect
The shim's only gate is checkSessionValid($db). grep -n "checkRights\|hasRight" on the file
returns line 14 only, inside the docblock — there is no rights check in the shim, by design
(it only computes a 302 target). So any authenticated user of the installation, holding no
reqmgrsystem_management right at all, can write 2 Event-Viewer rows per request. That is the same
reachability #1731 documents for the BFF, and it is the "Event-Viewer-only" defect class of
#1654 and
#1721 — a defect that exists only in
the log, adding noise exactly where a real Error would need to be spotted.
Not anonymously reachable: checkSessionValid() redirects before line 66, so the unauthenticated
case is clean (verified, 0 rows).
The fix
function shimReqScalar($name) // null when absent OR array-shaped, else the trimmed string
function shimReqInt($name) // 0 when absent, array-shaped or non-numeric
This is deliberately the same idiom already established in this repo, not an invention:
lib/attachments/attachmentdelete.php:33-38 and the bffQueryScalar() / bffQueryInt() helpers in
api/reqmgrsystemedit/index.php:95-105. Reusing it is what made the fix reviewable as obviously
equivalent to the BFF hardening a reviewer had already approved.
An array-shaped doAction is refused early and separately, not folded into the switch:
if ($doActionShaped) {
tLog('reqMgrSystemEdit.php shim: doAction is not a scalar value - ' .
'refusing to guess a modern target (Refs #1731).', 'INFO');
header('Location: ' . $listUrl, true, 302);
exit;
}
Why INFO and not ERROR, and why 302 and not 400 — the alternatives rejected
- 400 instead of 302. Rejected. This file is a bookmark redirector; its entire contract is "never 500, always 302 somewhere sane", and the legacy deep links it exists to serve must keep resolving. Turning a junk query string into a client error would change the redirector's contract for no security gain — the request is refused either way.
- ERROR instead of INFO. Rejected, and this is the substantive judgement call. The ERROR level
is reserved for a genuinely unknown verb (
?doAction=bogus), which is a real client mistake worth surfacing to an operator. An array-shaped parameter is never a real request at all, and must therefore never be able to write a row — attacker-controlled text landing in the Event Viewer is precisely the harm. Note that the pre-fix ERROR row was a downstream consequence of the coercion, not a deliberate signal: the log fired because the value had been silently corrupted into"Array". Removing the corruption removes the row at its source.
Array-shaped id / tproject_id / tplan_id degrade to 0, which is exactly the "no id / no
context" case the code already handles (the session fallback at testprojectID / testplanID).
No new response shape, no new code path — just the existing one, fed sanitised input.
No user-facing string changed (the shim emits only a log line), so no i18n keys were added.
Verification
Suite "Regression — Issue #1731", cases 53-66, tmp/TLU_Test_Cases.md — 14/14 PASS.
One request per case, events rows with log_level in (1,2) counted before and after:
| Case | Result |
|---|---|
?doAction[]=x |
302 → list screen, rows=+0 (was +2) |
?doAction[]=edit |
302 → list screen, rows=+0 |
?doAction[]=doDelete |
302 → list screen, rows=+0, no server-side write |
?id[]=1&doAction=edit |
302 → editor with no id (create mode), not ?id=1 |
?tproject_id[]=1 / ?tplan_id[]=1 |
302 → editor with no context parameter |
?doAction=edit&id=1&tproject_id=1 (scalar) |
302 → reqMgrSystemEdit.html?id=1&tproject_id=1 — legacy parity preserved |
?doAction=create |
302 → editor, no id |
?doAction=doCreate |
302 → list screen, INFO only |
?doAction=bogus (scalar unknown verb) |
302 → list screen, +1 ERROR — pre-existing and intentional, unchanged |
5 × ?doAction[]=x |
rows=+0 (was +10) — measured against the whole table, not just log_level in (1,2): 0 rows of any level, because tLog(...,'INFO') does not persist below WARNING. The refusal is silent apart from the 302 |
| anonymous | 302 to login, rows=+0 |
php -l |
no syntax errors |
| browser end-to-end | shim redirect → editor in Edit mode → rename SysA → SysA-renamed → Save → row persisted; 0 new Error/Warning rows; no console errors |
Blast radius
grep -rEn '\(string\)\$_REQUEST\[' --include=*.php lib gui → 4 hits in 3 files:
| File:line | Reachable? | Status |
|---|---|---|
lib/reqmgrsystems/reqMgrSystemEdit.php:66 |
yes, 2 rows/request | fixed here |
lib/testcases/listTestCases.php:52 |
yes, 2 rows/request | filed as #1732 |
lib/keywords/keywordsEdit.php:71,73 |
no — 302 before the cast, 0 rows | not a defect |
The intval($_REQUEST[...]) form is far more widespread, but most call sites sit behind a full
testlinkInitPage() + rights gate, and only those whose coerced value is later interpolated into a
log line can escalate to a row. No exhaustive audit of those was done and no count is claimed.