VYPR
Medium severity6.1NVD Advisory· Published Apr 15, 2026· Updated Apr 25, 2026

CVE-2026-40186

CVE-2026-40186

Description

ApostropheCMS is an open-source Node.js content management system. A regression introduced in commit 49d0bb7, included in versions 2.17.1 of the ApostropheCMS-maintained sanitize-html package bypasses allowedTags enforcement for text inside nonTextTagsArray elements (textarea and option). ApostropheCMS version 4.28.0 is affected through its dependency on the vulnerable sanitize-html version. The code at packages/sanitize-html/index.js:569-573 incorrectly assumes that htmlparser2 does not decode entities inside these elements and skips escaping, but htmlparser2 10.x does decode entities before passing text to the ontext callback. As a result, entity-encoded HTML is decoded by the parser and then written directly to the output as literal HTML characters, completely bypassing the allowedTags filter. An attacker can inject arbitrary tags including XSS payloads through any allowed option or textarea element using entity encoding. This affects non-default configurations where option or textarea are included in allowedTags, which is common in form builders and CMS platforms. This issue has been fixed in version 2.17.2 of sanitize-html and 4.29.0 of ApostropheCMS.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
sanitize-htmlnpm
>= 2.17.2, < 2.17.32.17.3

Affected products

2

Patches

1
7ca2d16237c7

Merge commit from fork

https://github.com/apostrophecms/apostropheTom BoutellApr 15, 2026via ghsa
3 files changed · +39 4
  • .changeset/pretty-mirrors-refuse.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +"sanitize-html": patch
    +---
    +
    +Fix vulnerability introduced in version 2.17.2 that allowed XSS attacks if the developer chose to permit `option` tags. There was no vulnerability when not explicitly allowing `option` tags.
    
  • packages/sanitize-html/index.js+8 4 modified
    @@ -566,10 +566,14 @@ function sanitizeHtml(html, options, _recursing) {
             // your concern, don't allow them. The same is essentially true for style tags
             // which have their own collection of XSS vectors.
             result += text;
    -      } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (nonTextTagsArray.indexOf(tag) !== -1)) {
    -        // htmlparser2 does not decode entities inside raw text elements like
    -        // textarea and option. The text is already properly encoded, so pass
    -        // it through without additional escaping to avoid double-encoding.
    +      } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) {
    +        // htmlparser2 treats <textarea> and <xmp> as raw text elements and
    +        // does NOT decode entities inside them. The text is already properly
    +        // encoded, so pass it through without additional escaping to avoid
    +        // double-encoding. Other "nonTextTags" like <option> are not raw text
    +        // elements in htmlparser2, so their contents are decoded and must be
    +        // escaped below like any other text (important to prevent XSS via
    +        // entity-encoded payloads such as <option>&lt;script&gt;...&lt;/script&gt;</option>).
             result += text;
           } else if (!addedText) {
             const escaped = escapeHtml(text, false);
    
  • packages/sanitize-html/test/test.js+26 0 modified
    @@ -1896,4 +1896,30 @@ describe('sanitizeHtml', function() {
     
         assert.equal(sanitizedHtmlPreservedAttrsTrue, sanitizedHtmlPreservedAttrsFalse);
       });
    +  it('should not allow script tag injection via escaped entities in option tag', () => {
    +    const inputHtml = '<option>&lt;script&gt;alert(1)&lt;/script&gt;</option>';
    +    const result = sanitizeHtml(inputHtml, { allowedTags: ['option'] });
    +    assert.strictEqual(result, '<option>&lt;script&gt;alert(1)&lt;/script&gt;</option>');
    +  });
    +  it('should not double-encode entities inside an allowed option element', function() {
    +    assert.equal(
    +      sanitizeHtml('<option>&lt;div&gt;hello&lt;/div&gt;&amp;amp;</option>',
    +        { allowedTags: [ 'option' ] }
    +      ), '<option>&lt;div&gt;hello&lt;/div&gt;&amp;amp;</option>'
    +    );
    +  });
    +  it('should not double-encode entities inside an allowed xmp element', function() {
    +    assert.equal(
    +      sanitizeHtml('<xmp>&lt;div&gt;hello&lt;/div&gt;&amp;amp;</xmp>',
    +        { allowedTags: [ 'xmp' ] }
    +      ), '<xmp>&lt;div&gt;hello&lt;/div&gt;&amp;amp;</xmp>'
    +    );
    +  });
    +  it('should correctly maintain escaping when allowing an xmp element', function() {
    +    assert.equal(
    +      sanitizeHtml('!<xmp>&lt;/xmp&gt;&lt;svg/onload=prompt`xs`&gt;</xmp>!',
    +        { allowedTags: [ 'xmp' ] }
    +      ), '!<xmp>&lt;/xmp&gt;&lt;svg/onload=prompt`xs`&gt;</xmp>!'
    +    );
    +  });
     });
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.