sanitize-html 2.17.5 → 2.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +41 -11
  2. package/package.json +5 -2
package/index.js CHANGED
@@ -540,8 +540,13 @@ function sanitizeHtml(html, options, _recursing) {
540
540
  result += ' />';
541
541
  } else {
542
542
  result += '>';
543
- if (frame.innerText && !hasText && !options.textFilter) {
544
- result += escapeHtml(frame.innerText);
543
+ if (frame.innerText && !hasText) {
544
+ const escaped = escapeHtml(frame.innerText);
545
+ if (options.textFilter) {
546
+ result += options.textFilter(escaped, name);
547
+ } else {
548
+ result += escaped;
549
+ }
545
550
  addedText = true;
546
551
  }
547
552
  }
@@ -573,14 +578,39 @@ function sanitizeHtml(html, options, _recursing) {
573
578
  // which have their own collection of XSS vectors.
574
579
  result += text;
575
580
  } else if (tag && tagAllowed(tag) && (options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) {
576
- // htmlparser2 treats <textarea> and <xmp> as raw text elements and
577
- // does NOT decode entities inside them. The text is already properly
578
- // encoded, so pass it through without additional escaping to avoid
579
- // double-encoding. Other "nonTextTags" like <option> are not raw text
580
- // elements in htmlparser2, so their contents are decoded and must be
581
- // escaped below like any other text (important to prevent XSS via
582
- // entity-encoded payloads such as <option>&lt;script&gt;...&lt;/script&gt;</option>).
583
- result += text;
581
+ // <textarea> and <xmp> hold text that must not be re-emitted verbatim:
582
+ // if a raw `<` survives into the output it can reopen a tag when the
583
+ // result is re-parsed by a browser, smuggling non-allowlisted markup
584
+ // through the allowlist (mutation-XSS, GHSA-jxwj-j7wr-gfrw e.g. the
585
+ // `</textarea/>` solidus mis-close). We therefore ALWAYS escape this
586
+ // content rather than passing it through. Escaping unconditionally also
587
+ // closes the related SVG/MathML foreign-content bypass (where the HTML5
588
+ // parser treats <textarea>/<xmp> as ordinary foreign elements and a
589
+ // browser re-parses their contents as live markup, e.g.
590
+ // `<svg><textarea><img src=x onerror=alert(1)></textarea></svg>`): since
591
+ // we never re-emit raw text for these tags, no namespace check is
592
+ // needed. The two tags need different escaping because htmlparser2
593
+ // tokenizes them differently:
594
+ if (tag === 'xmp') {
595
+ // <xmp> is a raw-text (CDATA) element: entities are NOT decoded, so
596
+ // its content reaches us as raw source that is already entity-encoded.
597
+ // Escape only the angle brackets so a literal `<` cannot reopen a tag,
598
+ // while leaving `&` untouched to avoid double-encoding entities that
599
+ // are already encoded in the source.
600
+ result += text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
601
+ } else {
602
+ // <textarea> is an RCDATA element: htmlparser2 (>= 11) decodes
603
+ // entities inside it, so its content reaches us as plain decoded text.
604
+ // It must therefore be fully escaped like any other text — escaping
605
+ // `&` as well as `<`/`>` — so entities round-trip faithfully (no
606
+ // double-encoding, see the CVE-2026-40186 regression tests) and no
607
+ // `<` can reopen a tag.
608
+ result += escapeHtml(text, false);
609
+ }
610
+ // Other "nonTextTags" like <option> are not raw text elements in
611
+ // htmlparser2, so their contents are decoded and must be escaped below
612
+ // like any other text (important to prevent XSS via entity-encoded
613
+ // payloads such as <option>&lt;script&gt;...&lt;/script&gt;</option>).
584
614
  } else if (!addedText) {
585
615
  const escaped = escapeHtml(text, false);
586
616
  if (options.textFilter) {
@@ -953,7 +983,7 @@ sanitizeHtml.defaults = {
953
983
  'alt'
954
984
  ],
955
985
  // Lots of these won't come up by default because we don't allow them
956
- selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ],
986
+ selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta', 'col' ],
957
987
  // URL schemes we permit
958
988
  allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ],
959
989
  allowedSchemesByTag: {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanitize-html",
3
- "version": "2.17.5",
3
+ "version": "2.17.6",
4
4
  "description": "Clean up user-submitted HTML, preserving allowlisted elements and allowlisted attributes on a per-element basis",
5
5
  "sideEffects": false,
6
6
  "main": "index.js",
@@ -21,10 +21,13 @@
21
21
  ],
22
22
  "author": "Apostrophe Technologies, Inc.",
23
23
  "license": "MIT",
24
+ "engines": {
25
+ "node": ">=22.12.0"
26
+ },
24
27
  "dependencies": {
25
28
  "deepmerge": "^4.2.2",
26
29
  "escape-string-regexp": "^4.0.0",
27
- "htmlparser2": "^10.1.0",
30
+ "htmlparser2": "^12.0.0",
28
31
  "is-plain-object": "^5.0.0",
29
32
  "parse-srcset": "^1.0.2",
30
33
  "postcss": "^8.3.11",