p PatchFirst

CVE-2026-45710

CVE-2026-45710 · FacturaScripts: Stored XSS in WidgetVariante and WidgetSubcuenta modal lists via HTML-attribute decoding of `Tools::noHtml`-escaped quotes inside `onclick=`

Cette page présente des faits issus de sources publiques. Consultez l’avis original avant toute mise à jour.

Cette page présente une partie des faits des sources. Consultez les avis liés pour la description complète et les versions concernées.

## Summary `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 `&#39;`, neutralising the JavaScript string break. The intuition is wrong: HTML attribute parsing **decodes character references before** the JavaScript fragment is parsed, so `&#39;` 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. ## Details ### the offending code `Core/Lib/Widget/WidgetVariante.php:298-330`: ```php 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 `&#39;`, `&quot;`, `&lt;`, `&gt;`. The defender therefore expects that any apostrophe a user typed becomes `&#39;` in the database, which renders inside the `onclick` attribute as `&#39;` and cannot break out of the surrounding `'...'` JS string literal. `Core/Lib/Widget/WidgetSubcuenta.php:290-305` has the identical shape: ```php 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`). ### why HTML-entity escaping does not protect a JavaScript string 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: ```html <tr onclick="widgetVarianteSelect('id', '1&#39;,alert(1),&#39;2');"> ``` After the tokenizer decodes `&#39;` to `'`, the JavaScript fragment passed to the script engine is: ```javascript 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: ```php $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. ### ways to plant the payload `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&#39;,alert(1),&#39;2`, which is 22 bytes. Three plant primitives are practical: 1. **Direct create** via `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. 2. **DB write** by anyone with raw SQL access (DBA or shared-hosting admin). `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. 3. **Plugin / API import.** `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 (`&#39;`), 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. ### why the recent fix wave missed this 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. ## PoC > **Live PoC verified 2026-05-01** against `http://127.0.0.1:8081/` running facturascripts at commit `24281ca`. A `Variante.referencia` value of `1',alert(1),'2` (planted via raw DB write below) renders inside `widgetVarianteSelect('0', '1&#39;,alert(1),&#39;2');` in the modal grid; opening the variant-picker modal in any user's browser (low-priv or admin) fires `alert(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: ```bash 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

CVSS 3.5 · Faible

Publication
Mise à jour

Pile logicielle: php

Produits et versions concernés

  • facturascripts/facturascripts Packagist

    Versions du logiciel
    • 2018.03
    • 2018.04
    • 2018.05
    • 2018.11
    • v2018.12
    • v2018.13
    • v2018.14
    • v2018.15
    • v2018.16
    • v2020.01
    • v2020.2
    • v2020.3
    • v2020.4
    • v2020.51
    • v2020.61
    • v2020.71
    • v2020.80
    • v2021
    • v2021.1
    • v2021.2
    • v2021.4
    • v2021.51
    • v2021.71
    • v2021.81
    • v2022.06
    • v2022.08
    • v2022.2
    • v2022.4
    • v2022.51
    • v2023.03
    • v2023.08
    • v2023.16
    • v2023.21
    • v2024
    • v2024.1
    • v2024.2
    • v2024.3
    • v2024.5
    • v2024.7
    • v2024.8
    • v2024.9
    • v2024.91
    • v2024.93
    • v2024.94
    • v2024.95
    • v2024.96
    • v2025
    • v2025.11
    • v2025.2
    • v2025.3
    • v2025.4
    • v2025.41
    • v2025.43
    • v2025.7
    • v2025.71
    • v2025.8
    • v2025.81
    • v2026
    • v2026.1
    Plages de versions concernées
    • ECOSYSTEM: ≤ 2026.1

    Aucun correctif publié

Sources originales

Vérifiez votre serveur →