Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions lib/NodeUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!--</nosc')` followed by `#text('ript ...')`
* contains `</noscript` in neither node, but their serialized bytes do, which
* terminates the surrounding <noscript>. A split comment delimiter hides the
* comment state the same way. Every other node type serializes into markup
* that starts with `<` (`<tag`, `<!--`, `<?`, `<!DOCTYPE`) and `</tag` only
* contains `<` at its first position, so a run of character data is the
* largest span such a token can be synthesized across.
*/
function coalesceCharacterData(node) {
var data = node.data;
for (var next = node.nextSibling; isCharacterDataNode(next); next = next.nextSibling) {
data += next.data;
}
return data;
}

function findCommentEnd(rawText, index) {
if (rawText.charAt(index) === '>')
return index + 1;
Expand Down Expand Up @@ -320,9 +347,19 @@ function serializeOne(kid, parent) {
parenttag = '';

if (hasRawContent[parenttag]) {
// Preserve actual child element markup in fallback elements such as
// <noscript>, but do not emit text-node payloads as raw HTML.
s += hasRawContentFallback[parenttag] ? escapeFallbackRawText(kid.data, parent.localName, fallbackRawContentTags(parent.parentNode)) : kid.data;
if (!hasRawContentFallback[parenttag]) {
s += kid.data;
} else if (!isCharacterDataNode(kid.previousSibling)) {
// Preserve actual child element markup in fallback elements such as
// <noscript>, but do not emit text-node payloads as raw HTML. The
// whole run of adjacent character data is escaped in one go, so the
// other nodes of the run were already emitted here.
s += escapeFallbackRawText(
coalesceCharacterData(kid),
parent.localName,
fallbackRawContentTags(parent.parentNode)
);
}
} else {
s += escape(kid.data);
}
Expand Down
161 changes: 161 additions & 0 deletions test/xss.js
Original file line number Diff line number Diff line change
Expand Up @@ -1077,3 +1077,164 @@ exports.fallbackRawTextCommentNodeEscapesAbruptClosingComment = async function (
);
}
};

exports.fallbackRawTextEscapesClosingTagSplitAcrossTextNodes = async function () {
// Neither node below contains `</noscript`, but their serialized bytes are
// adjacent and reconstruct it, which terminates the <noscript> and turns the
// payload into live markup. Angular SSR builds this shape from a `@for` over
// plain text interpolation.
const document = domino.createDocument('');
const noscript = document.createElement('noscript');
noscript.appendChild(document.createTextNode('<!--</nosc'));
noscript.appendChild(
document.createTextNode(`ript <!--gate><script>alert('split_text_nodes')</script>`),
);
document.body.appendChild(noscript);

const html = document.body.serialize();
html.should.equal(
`<noscript><!--&lt;/noscript <!--gate><script>alert('split_text_nodes')</script></noscript>`,
);
html.toLowerCase().should.not.containEql('</noscript ');

const reparsedDocument = domino.createDocument('<body>' + html + '</body>');
reparsedDocument.getElementsByTagName('script').length.should.equal(0);

const alerted = await alertFired(document.serialize());
alerted.should.equal(false, 'alert fired for: ' + html);

// The same shape is reachable from plain DOM APIs.
const splitDocument = domino.createDocument('');
const splitNoscript = splitDocument.createElement('noscript');
const text = splitDocument.createTextNode(
`<!--</noscript <!--gate><script>alert('split_text_nodes')</script>`,
);
splitNoscript.appendChild(text);
splitDocument.body.appendChild(splitNoscript);
text.splitText('<!--</nosc'.length);

splitNoscript.childNodes.length.should.equal(2);
splitDocument.body.serialize().should.equal(html);
};

exports.fallbackRawTextEscapesEveryClosingTagSplitAcrossTextNodes = async function () {
const fallbackTags = ['noscript', 'iframe', 'noembed', 'noframes'];

for (const tag of fallbackTags) {
// The leading `<!--` picks the comment-preserving branch, the one that
// emits raw bytes. Cut it everywhere, so no split position can smuggle the
// closing tag through a node boundary.
const payload = `<!--</${tag} <!--gate><script>alert('${tag}_split')</script>`;
for (let cut = 0; cut <= payload.length; cut++) {
const document = domino.createDocument('');
const el = document.createElement(tag);
el.appendChild(document.createTextNode(payload.slice(0, cut)));
el.appendChild(document.createTextNode(payload.slice(cut)));
document.body.appendChild(el);

const html = document.body.serialize();
// These are parsed in RAWTEXT state, so the element ends at the first
// `</tag`. It must never be emitted.
el.serialize().toLowerCase().should.not.containEql(`</${tag}`);
html.should.equal(
`<${tag}><!--&lt;/${tag} <!--gate><script>alert('${tag}_split')</script></${tag}>`,
);

const reparsedDocument = domino.createDocument('<body>' + html + '</body>');
reparsedDocument.getElementsByTagName('script').length.should.equal(0);
}

// Three adjacent text nodes cutting the closing tag twice.
const document = domino.createDocument('');
const el = document.createElement(tag);
el.appendChild(document.createTextNode(`<!--</${tag.slice(0, 2)}`));
el.appendChild(document.createTextNode(tag.slice(2)));
el.appendChild(document.createTextNode(` <!--gate><script>alert('${tag}_split')</script>`));
document.body.appendChild(el);

const html = document.body.serialize();
el.serialize().toLowerCase().should.not.containEql(`</${tag}`);

const alerted = await alertFired(document.serialize());
alerted.should.equal(false, `alert fired for split text nodes in <${tag}>: ` + html);
}
};

exports.fallbackRawTextEscapesAncestorClosingTagSplitAcrossTextNodes = async function () {
const fallbackTags = ['noscript', 'iframe', 'noembed', 'noframes'];

for (const ancestorTag of fallbackTags) {
for (const tag of fallbackTags) {
if (tag === ancestorTag) continue;

// The split closing tag targets the ancestor, so the payload has to
// survive the ancestor traversal too.
const payload = `<!--</${ancestorTag} <!--gate><script>alert('ancestor_split')</script>`;
for (let cut = 0; cut <= payload.length; cut++) {
const document = domino.createDocument('');
const ancestor = document.createElement(ancestorTag);
const el = document.createElement(tag);
el.appendChild(document.createTextNode(payload.slice(0, cut)));
el.appendChild(document.createTextNode(payload.slice(cut)));
ancestor.appendChild(el);
document.body.appendChild(ancestor);

// The inner content must carry a closing tag for neither element.
const content = el.serialize().toLowerCase();
content.should.not.containEql(`</${ancestorTag}`);
content.should.not.containEql(`</${tag}`);
ancestor.serialize().toLowerCase().should.not.containEql(`</${ancestorTag}`);
}
}
}
};

exports.fallbackRawTextDetectsCommentEndSplitAcrossTextNodes = function () {
// The comment delimiters can be split too. Node by node, `<!--x--` reads as
// an unterminated comment, so the serialized comment never closed and
// swallowed the rest of the response. The end has to be seen where the
// parser sees it.
const document = domino.createDocument('');
const noscript = document.createElement('noscript');
noscript.appendChild(document.createTextNode('<!--x--'));
noscript.appendChild(document.createTextNode('><script>alert("comment_end_split")//</script>'));
document.body.appendChild(noscript);

const html = document.body.serialize();
html.should.equal(
'<noscript><!--x-->&lt;script&gt;alert("comment_end_split")//&lt;/script&gt;</noscript>',
);

const reparsedDocument = domino.createDocument('<body>' + html + '</body>');
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('<!-- fall'));
noscript.appendChild(document.createTextNode('back comment -->'));
document.body.appendChild(noscript);

document.body.serialize().should.equal('<noscript><!-- fallback comment --></noscript>');
};

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('<noscript>abc<!--d-->ef</noscript>');
};