Packagist · facturascripts/facturascripts
FacturaScripts: Stored XSS in WidgetVariante and WidgetSubcuenta modal lists via HTML-attribute decoding of `Tools::noHtml`-escaped quotes inside `onclick=`
WidgetVariante::renderVariantList (Core/Lib/Widget/WidgetVariante.php:298-330) and WidgetSubcuenta::renderSubaccountList (Core/Lib/Widget/WidgetSubcuenta.php:290-321) build the <tr onclick="..."> row for each modal hit by concatenating the user-controlled referencia / codsubcuenta field directly into a single-quoted JavaScript string literal inside an HTML onclick attribute. The defender's intuition is that Tools::noHtml (called in Variante::test() and Subcuenta::test()) replaces ' with the HTML entity ', neutralising the JavaScript string break. The intuition is wrong: HTML attribute parsing decodes character references before the JavaScript fragment is parsed, so ' becomes a literal ' in the JavaScript context. An attacker who can store a value such as 1',alert(1),'2 in Variante.referencia (no special characters required, just one apostrophe) ends up with widgetVarianteSelect('id', '1',alert(1),'2'); executing in any user's browser the moment they open the variant-picker modal.
The recent 40bc701 and 8586b97 fixes corrected the same anti-pattern in three sister classes by switching to data-reference="..." + this.dataset.reference. The two widget classes audited here were missed by that fix wave.
Core/Lib/Widget/WidgetVariante.php:298-330:
protected function renderVariantList(): string
{
$items = [];
foreach ($this->variantes() as $item) {
$match = $item->{$this->match};
$description = Tools::textBreak($item->description(), 300);
...
$items[] = '<tr class="clickableRow" onclick="widgetVarianteSelect(\'' . $this->id . '\', \'' . $match . '\');">'
. '<td class="text-center">'
...
$this->match defaults to 'referencia' (WidgetVariante::__construct, line 42). $item->referencia was sanitised at write time by Variante::test() (Core/Model/Variante.php:392) which calls Tools::noHtml($this->referencia). Tools::noHtml (Core/Tools.php:499-504) replaces ', ", <, > with ', ", <, >. The defender therefore expects that any apostrophe a user typed becomes ' in the database, which renders inside the onclick attribute as ' and cannot break out of the surrounding '...' JS string literal.
Core/Lib/Widget/WidgetSubcuenta.php:290-305 has the identical shape:
foreach ($this->subcuentas() as $item) {
$match = $item->{$this->match};
...
$items[] = '<tr class="clickableRow" onclick="widgetSubaccountSelect(\'' . $this->id . '\', \'' . $match . '\');">'
. '<td class="text-center">'
. '<a href="' . $item->url() . '" target="_blank" onclick="event.stopPropagation();">'
...
$this->match defaults to 'codsubcuenta'; the value is Tools::noHtml-encoded by Subcuenta::test() (Core/Model/Subcuenta.php:213).
Per the HTML5 spec (and what every browser actually does), the value of an HTML attribute is processed by the character reference state of the tokenizer before any consumer sees it. By the time the onclick attribute value reaches the script engine, the bytes inside are the decoded string. Concretely, the HTML the browser receives is:
<tr onclick="widgetVarianteSelect('id', '1',alert(1),'2');">
After the tokenizer decodes ' to ', the JavaScript fragment passed to the script engine is:
widgetVarianteSelect('id', '1',alert(1),'2');
alert(1) runs as a third positional argument that JavaScript happily evaluates while building the call. The widgetVarianteSelect function ends up being called with four arguments and the side-effect of alert(1) (or any payload) has already occurred.
The recent 40bc701 AccountingModalHTML and 8586b97 SalesModalHTML / PurchasesModalHTML fix recognised this. Both replaced the onclick="...('"+ value +"')" pattern with:
$tbody .= '<tr ... data-subaccount="' . $code . '" onclick="$(...).modal(\'hide\');'
. ' return newLineAction(this.dataset.subaccount);">'
Where $code = static::html($subaccount->codsubcuenta) and static::html is htmlspecialchars(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). The HTML5 entity decode is deliberate: it normalises any double-encoded data so that the subsequent htmlspecialchars produces stable single-encoded output. The JavaScript then reads the value from this.dataset.*, which is the post-decoded attribute value, where the original quote is now literally inside a string property and cannot break out of any quote context.
WidgetSubcuenta and WidgetVariante were not migrated to this pattern.
Variante::test() (Core/Model/Variante.php:389-401) accepts up to 30 characters in referencia. The minimum payload is 13 bytes (1',alert(1),'2), comfortably under the limit. The Tools::noHtml pass during test() produces the stored form 1',alert(1),'2, which is 22 bytes.
Three plant primitives are practical:
EditProducto?action=save with the attacker-controlled referencia field. Because EditProducto is exposed to any user with the EditProducto permission (which roles like sales agent and warehouse manager commonly carry), this is a within-tenant primitive: a low-privilege salesperson plants the payload, an admin opens any sales document and clicks the variant picker.INSERT INTO variantes (referencia, ...) VALUES ('1\',alert(1),\'2', ...). This is what I used for the live test; the plant is permanent until the row is deleted.ApiAttachedFiles and the various import endpoints allow a low-privilege API key to land arbitrary referencia values that bypass the EditProducto permissions->onlyOwnerData filter.For WidgetSubcuenta, the codsubcuenta field is constrained to the exercise's longsubcuenta length (10 by default), and the regex-free Tools::noHtml pass turns one apostrophe into 5 bytes ('), so the post-noHtml string must equal the configured length exactly. A 5-byte payload (1',' is 4) plus padding is workable for compact bypass payloads such as '+x+' (where x is a previously-loaded global). DB-write planting (primitive 2) bypasses the length check entirely.
The fix wave centred on the Lib/AjaxForms/*ModalHTML.php files. Both audited widgets live in Lib/Widget/ and look superficially safe to a reviewer because every value is Tools::noHtml-escaped at storage. The actual decoding step happens inside the browser, not the PHP code, so the defect is not visible in a grep-based review of the PHP source unless the reviewer specifically looks for onclick="...('+ $field +')' shapes that put a Tools::noHtml-escaped value in a JavaScript string position inside an HTML attribute.
Live PoC verified 2026-05-01 against
http://127.0.0.1:8081/running facturascripts at commit24281ca. AVariante.referenciavalue of1',alert(1),'2(planted via raw DB write below) renders insidewidgetVarianteSelect('0', '1',alert(1),'2');in the modal grid; opening the variant-picker modal in any user's browser (low-priv or admin) firesalert(1)from the host page's realm.
Setup: the admin session at http://127.0.0.1:8081/ is at /tmp/fs-cookie2.
Step 1 - plant the payload (any of the three primitives works). DB-write primitive:
mysql -h127.0.0.1 -ufs -pfs facturascripts <<'SQL'
INSERT INTO productos (referencia, descripcion, codimpuesto, sevende, secompra, bloqueado, nostock, publico, stockfis, ventasinstock, observaciones)
VALUES ('XSSPRD', 'XSS via WidgetVariante', 'IVA21', 1, 1, 0, 1, 0, 0, 1, '');
INSERT INTO variantes (refer
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.