sanitize-html 2.17.5 → 2.17.7
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.
- package/README.md +13 -0
- package/index.js +86 -12
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -696,6 +696,19 @@ And you can forbid the use of protocol-relative URLs (starting with `//`) to acc
|
|
|
696
696
|
allowProtocolRelative: false
|
|
697
697
|
```
|
|
698
698
|
|
|
699
|
+
### SVG animations of URL attributes are discarded
|
|
700
|
+
|
|
701
|
+
For security reasons, if your `allowedTags` includes the SVG animation elements (`animate`, `animateColor`, `animateMotion`, `animateTransform` and `set`), note that an animation which targets a URL attribute is always discarded:
|
|
702
|
+
|
|
703
|
+
```html
|
|
704
|
+
<!-- Discarded: this would set the link's href to javascript: after sanitization -->
|
|
705
|
+
<animate attributeName="href" values="#safe;javascript:alert(1)" dur=".01s" fill="freeze">
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
An animation element carries no URL itself. It names the attribute it animates with `attributeName` and supplies the new value in `values`, `from`, `to` or `by`, which the browser copies into the target attribute after sanitization. Scheme checking those values is not sufficient, because `values` is a semicolon-separated *list* of destinations, so we discard the animation instead whenever `attributeName` selects `href`, `xlink:href` or any other attribute listed in `allowedSchemesAppliedToAttributes`.
|
|
709
|
+
|
|
710
|
+
Animations of attributes that are not URLs, such as `fill` or `opacity`, are unaffected.
|
|
711
|
+
|
|
699
712
|
### Discarding the entire contents of a disallowed tag
|
|
700
713
|
|
|
701
714
|
Normally, with a few exceptions, if a tag is not allowed, all of the text within it is preserved, and so are any allowed tags within it.
|
package/index.js
CHANGED
|
@@ -12,6 +12,17 @@ const mediaTags = [
|
|
|
12
12
|
];
|
|
13
13
|
// Tags that are inherently vulnerable to being used in XSS attacks.
|
|
14
14
|
const vulnerableTags = [ 'script', 'style' ];
|
|
15
|
+
// SVG SMIL animation elements. These do not carry a URL themselves: they
|
|
16
|
+
// retarget an attribute of another element, naming it with `attributeName`
|
|
17
|
+
// and supplying the new value(s) in `values`, `from`, `to` and `by`.
|
|
18
|
+
const svgAnimationTags = [
|
|
19
|
+
'animate', 'animatecolor', 'animatemotion', 'animatetransform', 'set'
|
|
20
|
+
];
|
|
21
|
+
// Attribute names that always name a URL sink, whatever
|
|
22
|
+
// `allowedSchemesAppliedToAttributes` has been narrowed to. A namespace prefix
|
|
23
|
+
// is ignored when matching, so `xlink:href` and any other prefixed spelling of
|
|
24
|
+
// `href` are covered.
|
|
25
|
+
const alwaysUrlAttributes = [ 'href' ];
|
|
15
26
|
|
|
16
27
|
function each(obj, cb) {
|
|
17
28
|
if (obj) {
|
|
@@ -272,7 +283,7 @@ function sanitizeHtml(html, options, _recursing) {
|
|
|
272
283
|
}
|
|
273
284
|
}
|
|
274
285
|
|
|
275
|
-
if (!tagAllowed(name) || (options.disallowedTagsMode === 'recursiveEscape' && !isEmptyObject(skipMap)) || (options.nestingLimit != null && depth >= options.nestingLimit)) {
|
|
286
|
+
if (!tagAllowed(name) || animatesUrlAttribute(name, attribs) || (options.disallowedTagsMode === 'recursiveEscape' && !isEmptyObject(skipMap)) || (options.nestingLimit != null && depth >= options.nestingLimit)) {
|
|
276
287
|
skip = true;
|
|
277
288
|
skipMap[depth] = true;
|
|
278
289
|
if (options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') {
|
|
@@ -540,8 +551,13 @@ function sanitizeHtml(html, options, _recursing) {
|
|
|
540
551
|
result += ' />';
|
|
541
552
|
} else {
|
|
542
553
|
result += '>';
|
|
543
|
-
if (frame.innerText && !hasText
|
|
544
|
-
|
|
554
|
+
if (frame.innerText && !hasText) {
|
|
555
|
+
const escaped = escapeHtml(frame.innerText);
|
|
556
|
+
if (options.textFilter) {
|
|
557
|
+
result += options.textFilter(escaped, name);
|
|
558
|
+
} else {
|
|
559
|
+
result += escaped;
|
|
560
|
+
}
|
|
545
561
|
addedText = true;
|
|
546
562
|
}
|
|
547
563
|
}
|
|
@@ -573,14 +589,39 @@ function sanitizeHtml(html, options, _recursing) {
|
|
|
573
589
|
// which have their own collection of XSS vectors.
|
|
574
590
|
result += text;
|
|
575
591
|
} else if (tag && tagAllowed(tag) && (options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) {
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
592
|
+
// <textarea> and <xmp> hold text that must not be re-emitted verbatim:
|
|
593
|
+
// if a raw `<` survives into the output it can reopen a tag when the
|
|
594
|
+
// result is re-parsed by a browser, smuggling non-allowlisted markup
|
|
595
|
+
// through the allowlist (mutation-XSS, GHSA-jxwj-j7wr-gfrw — e.g. the
|
|
596
|
+
// `</textarea/>` solidus mis-close). We therefore ALWAYS escape this
|
|
597
|
+
// content rather than passing it through. Escaping unconditionally also
|
|
598
|
+
// closes the related SVG/MathML foreign-content bypass (where the HTML5
|
|
599
|
+
// parser treats <textarea>/<xmp> as ordinary foreign elements and a
|
|
600
|
+
// browser re-parses their contents as live markup, e.g.
|
|
601
|
+
// `<svg><textarea><img src=x onerror=alert(1)></textarea></svg>`): since
|
|
602
|
+
// we never re-emit raw text for these tags, no namespace check is
|
|
603
|
+
// needed. The two tags need different escaping because htmlparser2
|
|
604
|
+
// tokenizes them differently:
|
|
605
|
+
if (tag === 'xmp') {
|
|
606
|
+
// <xmp> is a raw-text (CDATA) element: entities are NOT decoded, so
|
|
607
|
+
// its content reaches us as raw source that is already entity-encoded.
|
|
608
|
+
// Escape only the angle brackets so a literal `<` cannot reopen a tag,
|
|
609
|
+
// while leaving `&` untouched to avoid double-encoding entities that
|
|
610
|
+
// are already encoded in the source.
|
|
611
|
+
result += text.replace(/</g, '<').replace(/>/g, '>');
|
|
612
|
+
} else {
|
|
613
|
+
// <textarea> is an RCDATA element: htmlparser2 (>= 11) decodes
|
|
614
|
+
// entities inside it, so its content reaches us as plain decoded text.
|
|
615
|
+
// It must therefore be fully escaped like any other text — escaping
|
|
616
|
+
// `&` as well as `<`/`>` — so entities round-trip faithfully (no
|
|
617
|
+
// double-encoding, see the CVE-2026-40186 regression tests) and no
|
|
618
|
+
// `<` can reopen a tag.
|
|
619
|
+
result += escapeHtml(text, false);
|
|
620
|
+
}
|
|
621
|
+
// Other "nonTextTags" like <option> are not raw text elements in
|
|
622
|
+
// htmlparser2, so their contents are decoded and must be escaped below
|
|
623
|
+
// like any other text (important to prevent XSS via entity-encoded
|
|
624
|
+
// payloads such as <option><script>...</script></option>).
|
|
584
625
|
} else if (!addedText) {
|
|
585
626
|
const escaped = escapeHtml(text, false);
|
|
586
627
|
if (options.textFilter) {
|
|
@@ -743,6 +784,39 @@ function sanitizeHtml(html, options, _recursing) {
|
|
|
743
784
|
});
|
|
744
785
|
}
|
|
745
786
|
|
|
787
|
+
// True if this is an SVG SMIL animation element that animates a URL-bearing
|
|
788
|
+
// attribute, e.g. `<animate attributeName="href" values="#safe;javascript:...">`.
|
|
789
|
+
//
|
|
790
|
+
// Such an element carries no URL of its own: the browser copies the animation
|
|
791
|
+
// values into the target attribute *after* sanitization, so a `javascript:`
|
|
792
|
+
// destination reaches a live link sink without ever being scheme checked.
|
|
793
|
+
// `values` compounds this, because it is a semicolon-separated LIST of
|
|
794
|
+
// destinations: checking it as one flat URL only validates its first entry, so
|
|
795
|
+
// a leading `#safe` fragment carries the rest of the list past the policy.
|
|
796
|
+
//
|
|
797
|
+
// Re-checking each entry of each value attribute would leave the safety of the
|
|
798
|
+
// output resting on our imitation of SMIL list parsing, so we reject the
|
|
799
|
+
// animation on the strength of its target instead. Animations of attributes
|
|
800
|
+
// that are not URL sinks, such as `fill` or `opacity`, are unaffected.
|
|
801
|
+
function animatesUrlAttribute(name, attribs) {
|
|
802
|
+
if (svgAnimationTags.indexOf(name.toLowerCase()) === -1) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
const schemeCheckedAttributes = options.allowedSchemesAppliedToAttributes || [];
|
|
806
|
+
return Object.keys(attribs || {}).some(function(attributeName) {
|
|
807
|
+
if (attributeName.toLowerCase() !== 'attributename') {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
const target = (attribs[attributeName] || '').trim().toLowerCase();
|
|
811
|
+
// The target may be namespace prefixed (`xlink:href`). Which prefixes are
|
|
812
|
+
// in scope depends on the document, so consider the local name too.
|
|
813
|
+
const localName = target.slice(target.lastIndexOf(':') + 1);
|
|
814
|
+
return alwaysUrlAttributes.indexOf(localName) !== -1 ||
|
|
815
|
+
schemeCheckedAttributes.indexOf(target) !== -1 ||
|
|
816
|
+
schemeCheckedAttributes.indexOf(localName) !== -1;
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
746
820
|
function parseUrl(value) {
|
|
747
821
|
value = value.replace(/^(\w+:)?\s*[\\/]\s*[\\/]/, '$1//');
|
|
748
822
|
if (value.startsWith('relative:')) {
|
|
@@ -953,7 +1027,7 @@ sanitizeHtml.defaults = {
|
|
|
953
1027
|
'alt'
|
|
954
1028
|
],
|
|
955
1029
|
// 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' ],
|
|
1030
|
+
selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta', 'col' ],
|
|
957
1031
|
// URL schemes we permit
|
|
958
1032
|
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ],
|
|
959
1033
|
allowedSchemesByTag: {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanitize-html",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.7",
|
|
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": "^
|
|
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",
|