FACTURASCRIPTS/FACTURASCRIPTS · CVE-2026-45710 · Package
FIX → 2026.2
FacturaScripts builds the row click handler for its variant and subaccount picker modals by concatenating stored product or subaccount codes into a JavaScript string inside an HTML onclick attribute. A value containing an apostrophe lets an attacker run script in the browser of any user who opens the picker.
first public Jul 14, 2026 · 1 source
Check it with your coding agent
It checks your repository and tells you if you’re affected, with the fix.
Teams running FacturaScripts 2026.1 or earlier where users with product-editing rights can store variant references or subaccount codes. Exposure requires a user to open a variant or subaccount picker modal; installations on 2026.2 or later are not affected.
Not affected if fixed-version, no-untrusted-product-editors
Who cares cwe-116, cwe-79, facturascripts, php, self-hosted
composer show facturascripts/facturascripts and see whether it is 2026.1 or earlier.widgetVarianteSelect or widgetSubaccountSelect inside Core/Lib/Widget/WidgetVariante.php and Core/Lib/Widget/WidgetSubcuenta.php to confirm the vulnerable code is present.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 the JavaScript fragment is parsed, so becomes a literal in the JavaScript context. An attacker who can store a value such as in (no special characters required, just one apostrophe) ends up with executing in any user's browser the moment they open the variant-picker modal.
Are you affected?
Tell InfraStack what you run. It checks this one, and every next one, against your stack.
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
Upgrade to 2026.2
facturascripts/facturascripts: move to 2026.2 or later.''1',alert(1),'2Variante.referenciawidgetVarianteSelect('id', '1',alert(1),'2');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
This page as .md · .json · CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:U/C:L/I:L/A:N
GitHub Advisory Database, CC-BY 4.0. Posts quoted belong to their authors.