From b9bcb85dbf752c505b9d3b9e6d2ed10097e12c4f Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:34:39 -0500 Subject: [PATCH] fix: escape adjacent fallback raw-content text nodes as one run Escape adjacent Text and CDATA children together so closing tags and comment delimiters split across nodes cannot bypass fallback raw-content escaping. Character-data runs are the maximal spans that can reconstruct these tokens because every other serialized node starts with `<`. --- lib/NodeUtils.js | 43 ++++++++++++- test/xss.js | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/lib/NodeUtils.js b/lib/NodeUtils.js index 2c5c183..8af3e72 100644 --- a/lib/NodeUtils.js +++ b/lib/NodeUtils.js @@ -217,6 +217,33 @@ function escapeFallbackRawText(rawText, parentTag, ancestorTags) { return result; } +/** Whether `node` is serialized verbatim, without markup of its own. */ +function isCharacterDataNode(node) { + return !!node && + (node.nodeType === 3 /*TEXT_NODE*/ || node.nodeType === 4 /*CDATA_SECTION_NODE*/); +} + +/** + * Concatenates the data of `node` with the data of every character data + * sibling that immediately follows it. + * + * The parser sees one byte stream, so escaping fallback raw text one Text node + * at a time is not sound: `#text('<script>alert("comment_end_split")//</script>', + ); + + const reparsedDocument = domino.createDocument('
' + html + ''); + reparsedDocument.getElementsByTagName('script').length.should.equal(0); + reparsedDocument.body.firstChild.childNodes.length.should.equal(2); + reparsedDocument.body.firstChild.childNodes[0].nodeType.should.equal(8 /* COMMENT_NODE */); +}; + +exports.fallbackRawTextPreservesCommentSyntaxSplitAcrossTextNodes = function () { + // Guard against over-escaping: a legitimate comment split across + // interpolated text nodes still round trips as one comment. + const document = domino.createDocument(''); + const noscript = document.createElement('noscript'); + noscript.appendChild(document.createTextNode('')); + document.body.appendChild(noscript); + + document.body.serialize().should.equal(''); +}; + +exports.fallbackRawTextEmitsEveryAdjacentTextNodeExactlyOnce = function () { + // The run is escaped in one go, so check that nothing is dropped or emitted + // twice, including around a sibling that interrupts it. + const document = domino.createDocument(''); + const noscript = document.createElement('noscript'); + noscript.appendChild(document.createTextNode('a')); + noscript.appendChild(document.createTextNode('b')); + noscript.appendChild(document.createTextNode('c')); + noscript.appendChild(document.createComment('d')); + noscript.appendChild(document.createTextNode('e')); + noscript.appendChild(document.createTextNode('f')); + document.body.appendChild(noscript); + + document.body.serialize().should.equal(''); +};