Bugfix Issue 1530 RoleAssignment Stored XSS - sebiboga/testlink-upgraded GitHub Wiki
Issue: #1530
Branch: fix/issue-1530
Status: VERIFIED-FIXED (2026-09-17)
usersAssignProject.html and usersAssignPlan.html concatenate DB-controlled
display strings straight into HTML, so a test project, test plan, user or role
whose name contains markup executes JavaScript in the session of every user who
opens the screen. Stored XSS — the payload lives in the database, not in the URL.
Measured before the fix: a project named <img src=x onerror="alert(3)">
rendered raw into the project <option> and alert(3) fired on page load with
zero interaction, on both screens.
- Log in (admin/admin) and create a test project through the BFF from the app
origin:
fetch('/api/projects/index.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:'<img src=x onerror="alert(3)">', prefix:'XSS1530', active:1})})
- Browse
http://localhost:8082/gui/templates/usermanagement/usersAssignProject.html. Before fix:alert(3)fires on load;<option>contains a live<img>. After fix: the option shows the literal text<img src=x onerror="alert(3)">, no alert, no<img>element. - Repeat on
usersAssignPlan.html?tproject_id=1— same for the project option, and after selecting a plan the plan option, user login/name and inherited-role cells are escaped too.
The modern screens build DOM via HTML string concatenation and inject with
innerHTML/.append(). DB-sourced strings were interpolated raw:
-
usersAssignProject.html:121— project<option>: rawp.name -
usersAssignPlan.html:107,127— project and plan<option>: rawp.name -
usersAssignPlan.html:149— role<option>: rawrole.name -
usersAssignPlan.html:153,154— user cells: rawu.login,u.name -
usersAssignPlan.html:155— inherited-role cell: rawu.inheritedRoleName
usersAssignProject.html already had an esc() helper (added for #926) and used
it at lines 174/179/185/187/191/192, but line 121 was missed.
usersAssignPlan.html had no esc() helper at all, so every sink was raw.
Source of the data (api/roles/index.php): login (:546/:657),
getDisplayName() for user/role (:547/:519/:622), plan name (:615),
project name (:631), inheritedRoleName (:652-653). Project and plan names
live in nodes_hierarchy.name (the testprojects table holds only settings).
Why it broke: the Dashio rewrite replaced the legacy Smarty templates, whose output was server-side escaped, with plain HTML+JS that concatenates raw API strings. No encoding was reintroduced at the HTML sink.
Escape every DB-sourced value at the moment it enters HTML.
-
usersAssignProject.html:esc(p.name)/esc(p.id)at the project option;esc(role.id);esc(u.id)andesc(u.roleID)indata-uid/onchange. -
usersAssignPlan.html: added the identicalesc()helper and applied it top.name(project + plan options),role.name,u.login,u.name,u.inheritedRoleName, and thedata-uid/onchangeargs.
Why this method: escaping belongs exactly at the HTML sink, so the JSON API keeps
returning raw usable data and every consumer is forced to make its own context
decision. esc() encodes & < > " ', which is sufficient for both element text
and double-quoted attribute contexts. IDs are already intval()-cast server-side
(api/roles/index.php:519,545,548,615,622,631,656,659), so escaping them is
defence-in-depth, not the exploitable vector.
Alternatives rejected: server-side encoding in the BFF (would double-encode for
non-HTML consumers and is the wrong layer); switching the whole render to
textContent/DOM nodes (larger churn, same result for this targeted fix).
- 8 raw interpolation sites across the two role-assignment screens, all fixed.
- The same API (
meta/tproject-roles,meta/tplan-roles) is consumed only by these two screens. - A separate, unrelated minor bug found while building role fixtures is filed as
#1531
(
POST /api/roleswithrightIDs:[0]→ 500 instead of a 400 JSON error).
| Case | Result |
|---|---|
usersAssignProject with <img ...alert(3)> project → no alert, no <img>, escaped option text |
PASS |
usersAssignPlan?tproject_id=1 same project → no alert, no <img>
|
PASS |
Plan <img ...alert(4)>, user first name <img ...alert(5)>, role <img ...alert(6)> → all cells/options escaped, alerts:[], hasImg:false
|
PASS |
| Save-changes flow: role change → badge + enabled Save → PUT → reload shows new role | PASS |
Normal names render without double-encoding (admin, Testlink Administrator) |
PASS |
Inline JS syntax gate (new Function) on both files |
PASS |
events table after full walk |
only AUDIT/INFO rows + 1 LOCALIZATION warning caused by the injected test-role fixture via getDisplayName(), not by the fix |
Post-fix DOM measurement:
<option value="1"><img src=x onerror="alert(3)"></option>Screenshots:
- Post-fix
usersAssignProject:issue-1530-assignproject-fixed.png - Post-fix
usersAssignPlan(plan selected):issue-1530-assignplan-fixed.png
Regression suite: tmp/TLU_Test_Cases.md → Regression — Issue #1530
(7/7 PASS).