Bugfix Issue 1779 SuiteMove Unentitled Container Existence Oracle - sebiboga/testlink-upgraded GitHub Wiki
Issue 1779 β suiteMove BFF: a container of an UNENTITLED project still answered 403, an absent one 404
Issue: #1779
Branch: fix/issue-1779 Β· Fix commits: 20f14eeef, 34d5157b4 (+ test commit b65cee007)
Screen: gui/templates/testcases/suiteMove.html Β· BFF: api/suitemove/index.php
Status: FIXED & VERIFIED (2026-10-02)
With a valid session and no role on any test project, two requests that must be indistinguishable were not:
request (as a <no rights> user) |
answer |
|---|---|
GET /api/suitemove/index.php?action=init&tproject_id=1&container_id=3 (suite of project 1) |
403 {"code":"forbidden","message":"Insufficient rights on this test project"} |
GET /api/suitemove/index.php?action=init&tproject_id=1&container_id=999999 (exists nowhere) |
404 {"code":"not_found","message":"Container not found"} |
The same split existed on the two write paths β POST ?action=reorder&tproject_id=1&container_id=3
and POST ?action=move&tproject_id=1&node_id=3 β and on the project-root container
(container_id=1). One sweep of container_id therefore enumerated every suite and project-root
id of a test project the caller may not even look at, with zero privileges. This is the twin of
#1761, closed on
api/tcreorder but never closed on api/suitemove.
-
api/suitemove/index.phpβcase 'init'(:615-619),case 'reorder'(:922-926) andcase 'move'(:733-742) all hand the caller-suppliedcontainer_id/node_idto the shared resolversuitMoveProject(&$db, &$user, $requestedId, $containerId, $leakGuard, $leakMessage)(:332). -
Inside the resolver the container is looked up unconditionally (
:329-360): unknown id, wrong node type and orphaned node all answer the opaque404 not_found "Container not found", and atproject_idthat disagrees with the container's real owner also answers404(:361-363). At that point$tprojectId = $owner(:389) β the project in play came from the caller's node id. -
The defect: the guard meant "this refusal must be opaque" was armed on the inverse predicate,
// api/suitemove/index.php:373 (before) $leakGuard = (intval($requestedId) <= 0); // "the caller named NO project"
so as soon as the caller also named
tproject_id, the flag wasfalse. -
The rights check (
:407-417) therefore took the informative branch and answered403 forbidden "Insufficient rights on this test project"β the oracle. -
case 'move'passed the flag explicitly with the same wrong predicate (suitMoveProject($db, $user, $owner, 0, intval($tprojectId) <= 0), pre-fix:735-736), sonode_idhad the identical split.
#1759 introduced the guard, but armed it on "the caller named no project", which closes only the
"no tproject_id" shape (M16βM20 of tmp/verify_1759.sh). The comments added at that time
(βthe rights check is about the caller's OWN project and its 403 is correct, informative and leaks
nothingβ) were the wrong argument: naming project A does not entitle anybody to A's structure.
#1761 then corrected exactly this in the sibling BFF by arming the guard on the container
(api/tcreorder/index.php:276), and api/suitemove was never aligned.
// api/suitemove/index.php:388 (after)
$leakGuard = ($containerId > 0);
$tprojectId = $owner; // api/suitemove/index.php:741-742 (after)
list($tprojectId, $tproject) =
suitMoveProject($db, $user, $owner, 0, true);Status-only opacity was not enough, and row R13 of the new matrix caught it:
case 'move' answers "Suite not found" for a node_id that exists nowhere
(suitMoveNodeInfo() null branch, :718), so a guard that answered "Container not found" still told
exists from does-not-exist at the message level:
POST ?action=move&tproject_id=1&node_id=3 -> 404 {"message":"Container not found"} (before 3)
POST ?action=move&tproject_id=1&node_id=999999 -> 404 {"message":"Suite not found"}
// api/suitemove/index.php:332-333 (after)
function suitMoveProject(&$db, &$user, $requestedId, $containerId = 0, $leakGuard = false,
$leakMessage = 'Container not found')
...
// api/suitemove/index.php:412-417 (after)
if ($leakGuard) {
out(array('status' => 'error', 'code' => 'not_found',
'message' => $leakMessage), 404);
}
...
// the move action passes the message it already uses for an absent id
suitMoveProject($db, $user, $owner, 0, true, 'Suite not found');| Option | Why rejected |
|---|---|
keep 403, blank the message |
the status code is the oracle |
answer 403 only when the caller holds mgt_view_tc on the container's project |
the caller must not learn that the project exists either; it also re-introduces the status split |
make the move action answer "Container not found" for an absent id |
would have had to touch a second, unrelated code path and would change the wording the screen already shows for a missing suite |
404 for every unresolved project |
would hide the genuine "you have no rights in this project" answer from a request that named no node at all (see below) |
- A request naming no node (
?action=init&tproject_id=1) keeps its informative403 forbiddenβ the #1759 rule that a caller-named project id must stay a uniform403(rowsR3,R8,R9,R15). -
?action=suitespassescontainer_id = 0and takes no node id from the caller, so it keeps its informative403as well (rowsR7,R21) β no node id is hidden there. - The #1759 fixes themselves (the ownership check at
:361-363, the type/opaque messages, theno_context400). -
gui/templates/testcases/suiteMove.htmlβ:267/:270already map403 β smv.stNotAllowedand404 β smv.stNotFound. No i18n key added/removed, no locale bundle touched.
A view-only user of its own project that names a container_id now gets the opaque 404
instead of the informative 403. This is the same trade-off that api/tcreorder (#1761, row R25)
and api/tcstepsreorder (#1762, row S26) already accept; the user still learns "you may not work in
this project" from any request that names no container. Rows M12/M12b/M12c of
tmp/verify_1759.sh were updated in the same commit (34d5157b4) β M12 now expects 404,
M12b (no container) still expects the informative 403.
php tmp/fixtures_1759.php # two private projects, editor-of-A / view-only / no-rights users
bash tmp/verify_1779.sh # -> 28 passed, 0 failed (matrix added for this issue)
bash tmp/verify_1759.sh # -> 28 passed, 0 failed (1 SKIP: no test-case fixture, pre-existing)Suite Regression β Issue #1779 in tmp/TLU_Test_Cases.md: 28 PASS / 0 FAIL.
| Case | Before | After |
|---|---|---|
init&tproject_id=1&container_id=3 (unentitled suite) |
403 forbidden |
404 not_found |
init&tproject_id=1&container_id=1 (unentitled project root) |
403 forbidden |
404 not_found |
reorder&tproject_id=1&container_id=3&nodelist=3,4 |
403 forbidden |
404 not_found |
move&tproject_id=1&node_id=3&position=down |
403 forbidden |
404 not_found "Suite not found" (byte-identical to an absent id) |
init&tproject_id=1 (no container) |
403 forbidden |
403 forbidden |
suites&tproject_id=1 |
403 forbidden |
403 forbidden |
init&tproject_id=2 vs init&tproject_id=424242
|
both 403, identical |
both 403, identical (#1759 M21) |
init of an own suite / project root as the editor |
200 |
200 |
admin reorder inside project A |
200, order reversed |
200, order reversed (3,4 -> 4,3) and restored |
refused reorder of project B |
404, B untouched |
404, B untouched (node_order 5,6) |
events ERROR/WARNING rows |
0 |
0 (baseline, R28) |
- A curl GET with the parameters sent as a body (
-X GET -d β¦) leaves$_POSTempty server side, so the endpoint never seestproject_id/container_idand answers400 no_contextβ GET parameters must go in the query string (this made 14 rows fail on the first run). - Every session needs its own cookie jar, and the assertions must read the same variable the
login loop wrote (mixing them produced 5 Γ
401 session_expired).
- #1759 β the original cross-project
403onapi/suitemove(fixed; this issue is its remaining unentitled-project variant). - #1761 / #1762 β the same oracle class on
api/tcreorder/api/tcstepsreorder(fixed; theirtcreoProject()is the reference implementation followed here).
Verdict: SHIP β "the fix itself is correct, minimal, uniform with tcreoProject(), regression-free
for every user who had a working screen, and needs no i18n work".
Applied from the review:
-
MAJOR-in-spirit (test quality): row R11 of
tmp/verify_1779.shwas vacuous β it comparedcontainer_id=$A(the project root) against itself, because the substitution always replaced$SA1, which that request does not contain. The spec tuple now carries the id to swap per row and the row fails if the substitution did not actually change the request. The row is meaningful now:container_id=1andcontainer_id=999999really are compared byte for byte. - stale header comment above
M12intmp/verify_1759.shreworded (it still announced that the genuine403survives, directly above the row that now expects404); - the
file:linecitations in this page re-verified against the post-fix file (they drifted by ~7 lines because of the new docblock); the dangling link to a non-existent #1761 page replaced by the issue URL; thetmp/TLU_Test_Cases.mdpointer to this page corrected to the real file name.
Reported as a NEW bug issue instead of fixed here (pre-existing, needs rights on some project,
and expanding this run's scope is forbidden by ai/FIX-ISSUE.md Β§4):
-
api/suitemove/index.php:723and:729answer"Suite has no owning test project"for an existing suite of a foreign project, while an id that exists nowhere answers"Suite not found"β the same message-level oracle on themovepath as F1 below; -
api/suitemove/index.php:820/824/832β thenew_parent_id(destination) checks answer"Destination not found"/"Destination is not a test suite"/"Destination has no owning test project"; the middle one additionally leaks the node type across projects.