GHSA-X3Q4-9HXX-GX8M

Vulnerability from github – Published: 2026-10-20 18:03 – Updated: 2026-10-20 18:03
VLAI
Summary
ApostropheCMS sanitize-html: nonTextTags discard region ends early on an implied close, letting <noscript> content render
Details

Summary

sanitize-html discards the content of every element in nonTextTags. It delimits that region using htmlparser2's close events, and ignores the isImplied flag htmlparser2 passes it. Closing a still-open ancestor from inside a <noscript> makes htmlparser2 unwind its stack past the noscript; the synthetic implied close it emits on the way ends the discard region early, and everything after it is treated as ordinary body content.

A browser with scripting enabled parses <noscript> content as raw text, so the same markup renders as inert text there. The sanitizer therefore emits elements the page itself would not have.

Reproduction

const sanitizeHtml = require("sanitize-html");

sanitizeHtml('<div><noscript></div><img src="http://evil.test/t.gif"></noscript>', {
  allowedTags: ["div", "img"],
  allowedAttributes: { img: ["src"] },
  nonTextTags: ["script", "style", "textarea", "option", "noscript"],
});
// => '<div></div><img src="http://evil.test/t.gif" />'

Chrome, given the same input, renders no <img> — the whole payload is raw text inside the unclosed <noscript>. Verified with Playwright, srcdoc into an iframe so the scripting flag is on.

A <![CDATA[ variant hides the opening tag from the parser entirely and has the same effect:

<p>Hi</p><![CDATA[ x > <noscript>]]><a href="…">Reset your password</a><img src="…beacon.gif"></noscript>

htmlparser2 runs a CDATA token to ]]>; Chrome ends a bogus comment at the first >, so the <noscript> after it is real to the browser and invisible to the parser.

Where the behaviour is

index.js:

  • :154 — nonTextTagsArray (the default list, ['script','style','textarea','option','xmp']).
  • :638 — onclosetag: function (name, isImplied) — receives the flag.
  • :644 — skipTextDepth-- — decrements without consulting isImplied.

Suggested fix

Do not end a discard region on an implied close. htmlparser2 already distinguishes them; the flag is passed and discarded. Something close to:

onclosetag: function (name, isImplied) {
  if (skipText) {
    if (isImplied) return;   // an implied close must not end the region
    skipTextDepth--;
    …
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "sanitize-html"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.18.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-113654"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-20T18:03:22Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\n`sanitize-html` discards the *content* of every element in `nonTextTags`. It delimits that region\nusing htmlparser2\u0027s close events, and ignores the `isImplied` flag htmlparser2 passes it. Closing a\nstill-open **ancestor** from inside a `\u003cnoscript\u003e` makes htmlparser2 unwind its stack past the\n`noscript`; the synthetic implied close it emits on the way ends the discard region early, and\neverything after it is treated as ordinary body content.\n\nA browser with scripting enabled parses `\u003cnoscript\u003e` content as **raw text**, so the same markup\nrenders as inert text there. The sanitizer therefore emits elements the page itself would not have.\n\n## Reproduction\n\n```js\nconst sanitizeHtml = require(\"sanitize-html\");\n\nsanitizeHtml(\u0027\u003cdiv\u003e\u003cnoscript\u003e\u003c/div\u003e\u003cimg src=\"http://evil.test/t.gif\"\u003e\u003c/noscript\u003e\u0027, {\n  allowedTags: [\"div\", \"img\"],\n  allowedAttributes: { img: [\"src\"] },\n  nonTextTags: [\"script\", \"style\", \"textarea\", \"option\", \"noscript\"],\n});\n// =\u003e \u0027\u003cdiv\u003e\u003c/div\u003e\u003cimg src=\"http://evil.test/t.gif\" /\u003e\u0027\n```\n\nChrome, given the same input, renders **no** `\u003cimg\u003e` \u2014 the whole payload is raw text inside the\nunclosed `\u003cnoscript\u003e`. Verified with Playwright, `srcdoc` into an iframe so the scripting flag is on.\n\nA `\u003c![CDATA[` variant hides the opening tag from the parser entirely and has the same effect:\n\n```\n\u003cp\u003eHi\u003c/p\u003e\u003c![CDATA[ x \u003e \u003cnoscript\u003e]]\u003e\u003ca href=\"\u2026\"\u003eReset your password\u003c/a\u003e\u003cimg src=\"\u2026beacon.gif\"\u003e\u003c/noscript\u003e\n```\n\nhtmlparser2 runs a CDATA token to `]]\u003e`; Chrome ends a bogus comment at the first `\u003e`, so the\n`\u003cnoscript\u003e` after it is real to the browser and invisible to the parser.\n\n## Where the behaviour is\n\n`index.js`:\n\n- `:154` \u2014 `nonTextTagsArray` (the default list, `[\u0027script\u0027,\u0027style\u0027,\u0027textarea\u0027,\u0027option\u0027,\u0027xmp\u0027]`).\n- `:638` \u2014 `onclosetag: function (name, isImplied)` \u2014 receives the flag.\n- `:644` \u2014 `skipTextDepth--` \u2014 decrements without consulting `isImplied`.\n\n## Suggested fix\n\nDo not end a discard region on an implied close. htmlparser2 already distinguishes them; the flag\nis passed and discarded. Something close to:\n\n```js\nonclosetag: function (name, isImplied) {\n  if (skipText) {\n    if (isImplied) return;   // an implied close must not end the region\n    skipTextDepth--;\n    \u2026\n```",
  "id": "GHSA-x3q4-9hxx-gx8m",
  "modified": "2026-10-20T18:03:22Z",
  "published": "2026-10-20T18:03:22Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-x3q4-9hxx-gx8m"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/9500b8b0e1d630d5e07fbb5fd2c25ed0943845ce"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/f84fd41cbf6c22eb8d0008fc9c25df1f7130b350"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/apostrophecms/apostrophe"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/releases/tag/sanitize-html@2.18.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "ApostropheCMS sanitize-html: nonTextTags discard region ends early on an implied close, letting \u003cnoscript\u003e content render"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

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.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.