diff --git a/lib/NodeUtils.js b/lib/NodeUtils.js index 53e5927..8fe21bd 100644 --- a/lib/NodeUtils.js +++ b/lib/NodeUtils.js @@ -137,12 +137,15 @@ function escapeMatchingClosingTag(rawText, parentTag) { if (!rawText.toLowerCase().includes(parentClosingTag)) { return rawText; // fast path } - const result = [...rawText]; - const matches = rawText.matchAll(new RegExp(parentClosingTag, 'ig')); - for (const match of matches) { - result[match.index] = '<'; - } - return result.join(''); + // Replace via String.prototype.replace so we don't have to reconcile + // UTF-16 code-unit offsets (match.index) with code-point indexing + // (`[...rawText]`). Astral characters (e.g. emoji) before the match + // would otherwise shift the replacement and leave a real `` + // break-out in the output. + return rawText.replace( + new RegExp(parentClosingTag, 'ig'), + (m) => '<' + m.slice(1) + ); } const CLOSING_COMMENT_REGEXP = /--!?>/; @@ -191,7 +194,9 @@ function serializeOne(kid, parent) { var ss = kid.serialize(); // If an element can have raw content, this content may // potentially require escaping to avoid XSS. - if (hasRawContent[tagname.toUpperCase()]) { + var upperTag = tagname.toUpperCase(); + if (hasRawContent[upperTag] || + (upperTag === 'NOSCRIPT' && kid.ownerDocument._scripting_enabled)) { ss = escapeMatchingClosingTag(ss, tagname); } if (html && extraNewLine[tagname] && ss.charAt(0)==='\n') s += '\n'; diff --git a/test/xss.js b/test/xss.js index 1e60da5..143484f 100644 --- a/test/xss.js +++ b/test/xss.js @@ -179,6 +179,41 @@ exports.styleMatchingClosingTagSkipsUnclosedCommentedContent = function () { return alertFired(html).should.eventually.be.false('alert fired for: ' + html); }; +exports.noscriptMatchingClosingTagInRawText = function () { + // Use a parsed document so `_scripting_enabled` is true, matching how + // Angular SSR / platform-server uses domino. With scripting enabled, + // '; + document.body.appendChild(noscript); + + document.body + .serialize() + .should.equal(''); + + const html = document.serialize(); + return alertFired(html).should.eventually.be.false('alert fired for: ' + html); +}; + +exports.iframeMatchingClosingTagWithAstralPrefix = function () { + // Astral characters (e.g. emoji) before a `` inside iframe text + // content must still trigger the closing-tag escape, otherwise the + // payload breaks out and a sibling "; + + const html = document.serialize(); + html.should.not.match(/<\/iframe>', ], + + // Astral (non-BMP) characters before the closing tag must not shift + // the position of the escape: regex `match.index` is a UTF-16 code-unit + // offset while a code-point array would be off by one per astral char. + [ + '\uD83D\uDE00'.repeat(20) + '', + 'iframe', + '\uD83D\uDE00'.repeat(20) + '</iframe>', + ], + [ + '\uD83D\uDE00', + 'style', + '\uD83D\uDE00</style>', + ], ]; for (const [rawContent, parentTag, expected] of cases) { NodeUtils.ɵescapeMatchingClosingTag(rawContent, parentTag).should.equal(expected);