Bugfix Issue 1430 usersView modal options Stored XSS - sebiboga/testlink-upgraded GitHub Wiki
Issue 1430 — usersView.html: edit-modal <option> labels appended unescaped — stored-XSS via role names
Issue: #1430
Branch: fix/issue-1430
Status: VERIFIED-FIXED (2026-09-11)
The Create/Edit User modal option lists in gui/templates/usermanagement/usersView.html
are built by string concatenation into <option> HTML without escaping:
sel.append('<option value="' + role.id + '">' + role.name + '</option>'); // line 247A role stored with an HTML payload in its description (rolesView accepts it
server-side unescaped) was parsed as a real element inside the <option> — the
<img onerror> handler executed. Because loadMeta() runs at page init
(loadGrants → loadMeta(cb), usersView.html:200), the XSS fired on plain page
load of the usersView screen, and again when the Create/Edit modal was opened — the
ticketer originally assumed only the modal path was exposed; the drive-by page-load path
is strictly worse.

- Plant a role description containing markup, e.g. (fresh fixture):
INSERT INTO roles (description, notes) VALUES ('"><img src=x onerror=document.title=\'MODALXSS1430\'>x','xss-fixture');Leading>keeps the name outside the<...>system-role translation intlRole::getDisplayName()(lib/functions/tlRole.class.php:261), so the payload is served verbatim byGET /api/users/index.php/meta/roles. - Log in admin → open
gui/templates/usermanagement/usersView.html?tproject_id=0&tplan_id=0. - Measured before fix: the tab title becomes
MODALXSS1430immediately on load; the Create modal's Global Role select shows the payload shell with a real<img>element (option label rendered as">x). No operator click required.
- Four sinks in
loadMeta()(usersView.html:241-273) interpolate labels raw into<option>markup:role.name(247),loc.name(255), authcfgDesc/it.label(270/272). - Of these, only
role.nameis user-controlled —roles.descriptionis written verbatim via rolesView. The other three are server-configuration literals:config_get('locales')(cfg/const.inc.php:282) andconfig_get('authentication')['domain'](config.inc.php:465-466). - The locale config values are pre-HTML-entity-encoded by design
(
'fr_FR' => 'Français','ro_RO' => 'Română'); the legacy Smarty path renders them throughhtmlspecialchars(..., double_encode=false). Naively applying the client-sideesc()toloc.namedouble-encodes them — measured corruption: the France option displayed the literal textFrançais. - The grid path was already fixed by #1406 (
esc()on text cells, committed a7e97345a);loadMeta()predates that convention and was left raw. Theesc()helper itself already exists at usersView.html:173 (introduced by #1406).
Wrap the single user-controlled sink with the existing esc() helper (usersView.html:173):
sel.append('<option value="' + role.id + '">' + esc(role.name) + '</option>'); // line 247value stays the plain numeric role id. loc.name, auth cfgDesc and auth it.label
are config-owned, pre-entity-encoded literals — escaping them would corrupt the UI
(verified and reverted), and they carry no user input, so they are not XSS vectors.
No backend change: output encoding belongs at the render boundary.
Alternatives rejected: (a) esc() on all four sinks — breaks Français/Română
display via double-encoding; (b) decoding config entities client-side before
re-escaping — fragile, and unnecessary for config-owned data.
Regression suite Regression — Issue #1430 in tmp/TLU_Test_Cases.md (8 cases):
- 1430.1 pre-fix control → page title flips to
MODALXSS1430on load, base modal option shows<img>consumed; - 1430.2 post-fix → title stays
User Management, 0<img>elements in#editRole, payload option text renders the literal"><img src=x onerror=...'>x; - 1430.3 no double-encode regression —
fr_FRlabelFrançais,ro_RORomână, authDefault (DB)/DB/LDAP; - 1430.4 create user
xss_test_user(role tester) via the modal → row appears; - 1430.5 edit that user → title
Edit User: xss_test_user, role pre-selectedtester, modal contains 0<img>, title unchanged; - 1430.6 "Manage user" lookup →
Edit User: admin; - 1430.7 rolesView cross-check — role grid column still renders the payload escaped
(post-#1406); a new same-family sink observed in the rolesView delete-cell
onclick→ filed as #1432; - 1430.8 hygiene —
eventstable shows only info-level audit rows (login + user creation), 0 new Error/Warning. Result: 8/8 PASS.

-
gui/templates/usermanagement/usersView.html(+1/-1) -
docs/screenshots/issue-1430-modal-xss-repro.png/...-fixed.png -
tmp/TLU_Test_Cases.md(suite commit)
Same-family bug discovered while testing (out of scope, filed):
-
rolesView.htmldelete action interpolatesrole.nameinto an inlineonclick="confirmDelete(id, '<name>')"attribute with only single-quote escaping — a role description containing"breaks out of the attribute and executes on grid render (verified:location.hashflips to#RVXSS) → #1432.