Bugfix Issue 1406 usersView TextCells Stored XSS - sebiboga/testlink-upgraded GitHub Wiki
Issue: #1406
Branch: fix/issue-1406-usersview-xss
Status: VERIFIED-FIXED (2026-09-11)
The modernized User Management grid (gui/templates/usermanagement/usersView.html)
pushed every text cell (login, first/last name, email, globalRoleName, locale) into
DataTables as a raw string. DataTables 1.13.7 injects cell values via td.innerHTML
without escaping (escaping only became the default in DataTables 2.x), so a stored
value such as <img src=x onerror=…> executes as soon as the grid renders. Browser
measurement on this fixture: creating a user whose firstName is
</td><img src=x onerror=document.title="XSSFIRED"> and reloading the grid flipped the
page title to XSSFIRED and left the raw <img> markup inside the cell — stored XSS
confirmed.

- Log in admin/admin at http://localhost:8082.
-
PUT /api/users/index.php/{id}(orPOST) with a payload in a name field, e.g.{"firstName":"</td><img src=x onerror=document.title=\"XSSFIRED\">"}— the BFF stores it verbatim (no HTML validation on write). - Open
gui/templates/usermanagement/usersView.html?tproject_id=0&tplan_id=0. - The payload renders as live markup. Before the fix the handler executed
(
document.title === 'XSSFIRED').
Note: the original report's locale vector is not storable on this fixture —
users.locale is varchar(10) and MariaDB strict mode rejects longer values (the BFF
then leaks a raw DB debug backtrace; tracked separately as #1429). The real stored
vectors are the wider name/email columns (first/last varchar(50), email varchar(100)).
- The BFF's PUT handler (
api/users/index.php:282-288) acceptsfirstName/lastName/email/localeand persists them throughtlUser::writeToDB()(lib/functions/tlUser.class.php:435-449). That path is SQL-safe (prepare_string()) but intentionally stores HTML as-is — escaping is a render-time responsibility. -
renderTable()(gui/templates/usermanagement/usersView.html:292-312) built DataTables row arrays from the raw strings; DataTables 1.13.7 setstd.innerHTMLwithout escaping. - Bonus sink in the same block: the delete icon interpolated
u.loginraw into anonclick="deleteUser(id, '<login>')"attribute — attribute-injection vector of the same chain. - Why it breaks NOW: this screen was modernized before the
esc()convention existed; its siblingrolesView.htmlalready shipped an identical helper for exactly this.
- Added the existing
esc()helper —document.createElement('div')+createTextNode(s)+innerHTMLread-back — so DataTables'innerHTMLassignment receives HTML-entity-escaped text and renders it literally. - Wrapped all seven text cells (
login,firstName,lastName,email,globalRoleName,locale,expirationDateFormatted) inesc(). Status badge and action cells stay explicit HTML by design (they only carry hardcoded markup, numericu.idand i18n titles). - Delete action no longer embeds
u.loginin theonclick: it now passes onlyu.idanddeleteUser()resolves the login from theallItemsarray by id — the exact patternresetPassword()already used in the same file.confirm()renders it as a JS string, not HTML, so it is safe by construction.
Method chosen on purpose: reuse the proven in-repo convention instead of inventing a
new one (alternative rejected: wrapping values in a DataTables render/columns
callback would have changed ordering/search semantics and duplicated the helper).
No backend change needed — output encoding belongs at the render boundary.
Regression suite Regression — Issue #1406 in tmp/TLU_Test_Cases.md (7 cases):
- 1406.1 pre-fix control → payload executed (
document.title === 'XSSFIRED'), raw<img>markup found intd.innerHTML; - 1406.2 post-fix → title stays
User Management, cellinnerHTMLis<…>entities,textContentis the literal payload; - 1406.3 normal grid renders (admin row, Active/Inactive badges, action icons);
- 1406.4 edit modal opens on the payload user (login readonly, values pre-filled via
.val()— no execution); - 1406.5 delete on a non-admin user works (confirm shows login, row removed);
- 1406.6 DataTables search unaffected;
- 1406.7 hygiene — no new Error/Warning events from the fixed render path. Result: 7/7 PASS.

-
gui/templates/usermanagement/usersView.html(+16/-9) -
docs/screenshots/issue-1406-usersview-xss-before.png/...-after.png -
tmp/TLU_Test_Cases.md(suite commit 2)
Pre-existing bugs discovered while testing (out of scope, filed):
- BFF PUT with a
localelonger thanvarchar(10)returns the raw "DB Access Error - debug_print_backtrace()" HTML page with HTTP 200, leaking absolute server paths (CWE-200) → #1429. - Edit-modal
<option>labels (role.name,loc.name, authlabel/description) are appended unescaped in usersView (loadMeta(), lines 247/255/270-272) — a narrower, admin-gated variant of the same family → #1430.