Bugfix Issue 1759 SuiteMove 403 Leaks Foreign Suite - sebiboga/testlink-upgraded GitHub Wiki
Issue: #1759
Branch: fix/issue-1759 · Fix commit: 30b6056ea (api/suitemove/index.php, +4/-2 code)
Screen: gui/templates/testcases/suiteMove.html · BFF: api/suitemove/index.php
Status: FIXED & VERIFIED (2026-09-30)
Move / Reorder Test Suites answered
403 forbidden {"code":"forbidden","message":"Container belongs to another test project"}
whenever the container_id of an init or reorder request belonged to a different test
project than the tproject_id the caller named — while the very same endpoint answers a plain
404 not_found / Container not found for a container id that does not exist anywhere.
Those two answers are the only difference a caller can observe between
- “id 56 is a test suite that lives in a project you have no rights on”, and
- “id 56 does not exist”.
So the endpoint was a cross-project existence oracle: one 403 per existing suite is enough to
enumerate the suite ids of any test project of the installation, while holding rights on nothing
but one's own project. Reachable on the read path (GET ?action=init) and on the write path
(POST ?action=reorder).
-
api/suitemove/index.php—case 'init'andcase 'reorder'— both pass the client-suppliedcontainer_idinto the shared resolversuitMoveProject($db, $user, $requestedId, $containerId)(:316). - →
suitMoveNodeInfo()(:213) loads the container with an unpredicatedSELECT id, name, parent_id, node_type_id, node_order FROM nodes_hierarchy WHERE id = ?(:225-227). No project predicate, so a row of project B is fetched exactly like a row of project A. -
:318the real owner comes from theparent_idwalk ofsuitMoveOwningProject()(:239-266). - On disagreement the resolver answered
403 forbidden— before the rights check (now:391), and before anything could be known about the caller. - The genuine insufficient-rights
403is correct and untouched for a project the caller named.
The two move-path ownership checks had already been hardened to 404 by commit 13f2ca183
(“#1740: apply code-review fixes to Move/Reorder Test Suites”):
-
case 'move'— moved node of a foreign project →404(“404, not 403: a 403 would tell a caller who has rights on project A that node N exists inside another project B.”) -
case 'move'— destination of a foreign project →404
That commit did not touch the resolver, which came in with the endpoint itself (a45462e2c)
and predates both. So the screen shipped with three of four ownership checks hardened and one
left answering 403. The resolver's own docblock at :293-301 even advertised the property the
code did not implement.
One call site inside suitMoveProject():
// BEFORE
if (intval($requestedId) > 0 && intval($requestedId) !== $owner) {
out(array('status' => 'error', 'code' => 'forbidden',
'message' => 'Container belongs to another test project'), 403);
}
// AFTER
if (intval($requestedId) > 0 && intval($requestedId) !== $owner) {
out(array('status' => 'error', 'code' => 'not_found',
'message' => 'Container not found'), 404);
}Why the message was changed too, and not just the status code: a 403-turned-404 that still
says “belongs to another test project” would leave a softer wording oracle. The message is now
byte-identical to the non-existent-id answer produced a few lines above it.
| Option | Why rejected |
|---|---|
keep 403, blank the message |
the status code is the oracle; this fixes nothing |
move the ownership check behind hasRight()
|
would still announce nothing about project B, but breaks the legitimate “container of project A, caller has no rights on A” case whose 403 is correct and informative |
return 404 for every unresolved container |
a missing container_id must stay a 400 bad_request
|
-
gui/templates/testcases/suiteMove.html—:263-269already maps404 → smv.stNotFoundand:409reserves403for the CSRF refusal. No new i18n key, no locale bundle touched. -
suitMoveRequireSuite(), themove-path checks and the rights 403 of a project the caller itself named.
A code review over the whole diff found the first fix incomplete: the mismatch check was
guarded by intval($requestedId) > 0, so a caller that simply omitted tproject_id made the
resolver retarget onto the container's own project and then answer 403 Insufficient rights…
for the very id it was probing — the oracle, one step further along:
GET ?action=init&tproject_id=52&container_id=56 -> 404 not_found (fixed)
GET ?action=init&container_id=56 -> 403 forbidden (BYPASS)
GET ?action=init&container_id=999999 -> 404 not_found
POST ?action=reorder&container_id=56&nodelist=… -> 403 forbidden (BYPASS, write path)
POST ?action=move&node_id=56&position=down -> 403 forbidden (BYPASS, write path)
Two further findings from the same review, all closed in the same commit:
| Finding | Fix |
|---|---|
the project id was itself an existence oracle (?tproject_id=<other> → 403 vs ?tproject_id=424242 → 404) |
the refusal is now uniform 403 forbidden; existence is not observable, and a caller that has rights still gets its 200 |
'Container is not a test suite' / 'Container has no owning test project' still named the node type, so any nodes_hierarchy id was probe-able by message |
both now answer 'Container not found', byte-identical to a non-existent id |
| the 403 must NOT be swallowed when the caller named its own project |
$leakGuard is armed only when the project in play was derived from a caller-supplied node id, so the view-only user still gets the informative 403 |
// suitMoveProject(), 5th parameter
function suitMoveProject(&$db, &$user, $requestedId, $containerId = 0, $leakGuard = false)
...
// Naming no project at all is the same leak with one more step …
$leakGuard = (intval($requestedId) <= 0);
...
if (is_null($tproject) || !$user->hasRight($db, 'mgt_modify_tc', $tprojectId)) {
if ($leakGuard) {
out(array('status' => 'error', 'code' => 'not_found',
'message' => 'Container not found'), 404);
}
out(array('status' => 'error', 'code' => 'forbidden',
'message' => 'Insufficient rights on this test project'), 403);
}bash tmp/verify_1759.sh — 28/28 PASS after the code-review round (fixture tmp/fixtures_1759.php: two private
projects, an editor of A only, a view-only user and a <no rights> user). Suite
Regression — Issue #1759 in tmp/TLU_Test_Cases.md: 32 PASS / 0 FAIL / 1 skipped
(28 API assertions + 4 screen checks).
| Case | Before | After |
|---|---|---|
GET init tproject_id=52 container_id=56 (foreign suite) |
403 forbidden |
404 not_found |
GET init tproject_id=52 container_id=999999 |
404 not_found |
404 not_found (identical) |
POST reorder tproject_id=52 container_id=56 nodelist=56,57 |
403 forbidden |
404 not_found, project B node_order untouched |
GET init … container_id=54 (own suite) |
200 ok |
200 ok |
POST reorder … nodelist=55,54 (own project) |
200 ok |
200 ok, DB really reversed |
| view-only user, own project | 403 forbidden |
403 forbidden (rights 403 survives) |
CSRF-less POST reorder
|
403 |
403 |
Browser (sm1759a):
- foreign
container_id→ “Not found — The requested test project, container or test suite does not exist.” instead of thesmv.stNotAllowedstate. - own
container_id→ normal screen; destination picker lists only project A's suites. -
Move down on the project root really flips
nodes_hierarchy.node_order(55→0, 54→1), notice “The test suite was moved down.” - 0 console errors/warnings; Event Viewer ERROR/WARNING count unchanged.
Screenshots: docs/screenshots/issue-1759-foreign-container-not-found.png,
docs/screenshots/issue-1759-own-project-move-still-works.png.
-
api/tcreorder/index.php:234andapi/tcstepsreorder/index.php:530still answer403 … belongs to another test project— the same defect class in the sibling BFFs. -
tlUser::hasRight()is called without its$getAccessargument atapi/suitemove/index.php:347, so the private-project flag is never evaluated and a user with no project role at all passes the rights check. Separate issue.