---
id: "CVE-2026-45710"
page: "https://infrastack.ai/live/CVE-2026-45710"
kind: "vulnerability"
aliases: ["CVE-2026-45710", "GHSA-3x7p-v8hj-xh5m"]
title: "FacturaScripts: Stored XSS in WidgetVariante and WidgetSubcuenta modal lists via HTML-attribute decoding of `Tools::noHtml`-escaped quotes inside `onclick=`"
severity: "low"
cvss: 3.5
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "facturascripts/facturascripts", "fixed": ["2026.2"], "affected": "<= 2026.1", "ecosystem": "composer"}]
ecosystems: ["composer"]
tags: ["cwe-116", "cwe-79", "facturascripts", "package", "php", "self-hosted"]
not_affected_if: ["fixed-version", "no-untrusted-product-editors"]
published_at: "2026-07-14T17:30:26+00:00"
first_seen_at: "2026-11-06T19:02:24+00:00"
updated_at: "2026-11-06T19:04:07+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-3x7p-v8hj-xh5m", "first_seen_at": "2026-11-06T19:02:24+00:00"}]
exploit_sources: []
---

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

Link for people: https://infrastack.ai/live/CVE-2026-45710 (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- composer:facturascripts/facturascripts affected <= 2026.1 fixed 2026.2

## How to check

- Check the installed FacturaScripts version with `composer show facturascripts/facturascripts` and see whether it is `2026.1` or earlier.
- Look for `widgetVarianteSelect` or `widgetSubaccountSelect` inside `Core/Lib/Widget/WidgetVariante.php` and `Core/Lib/Widget/WidgetSubcuenta.php` to confirm the vulnerable code is present.
- Search the database for variant references or subaccount codes that contain apostrophes or quote characters.

## Fix

- `facturascripts/facturascripts`: move to `2026.2` or later.

## Timeline

- 2026-11-06T19:02:24+00:00 ghsa https://github.com/advisories/GHSA-3x7p-v8hj-xh5m

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/NeoRazorX/facturascripts/security-advisories/GHSA-3x7p-v8hj-xh5m
- [advisory] Maintainer's advisory: github.com https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-3x7p-v8hj-xh5m
- [fix] Patch: github.com https://github.com/NeoRazorX/facturascripts/commit/65779fc73f1b7f1fd7294fab083fe592d8a9b783

## From the sources

## 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

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
