diff --git a/lib/NodeUtils.js b/lib/NodeUtils.js index ad5f7e2..c35c21d 100644 --- a/lib/NodeUtils.js +++ b/lib/NodeUtils.js @@ -117,6 +117,16 @@ function escapeAttr(s) { }); } +function serializeForeignRawText(element) { + var s = ''; + for (var child = element.firstChild; child; child = child.nextSibling) { + s += child.nodeType === 3 /*TEXT_NODE*/ || + child.nodeType === 4 /*CDATA_SECTION_NODE*/ ? + escape(child.data) : serializeOne(child, element); + } + return s; +} + function attrname(a) { var ns = a.namespaceURI; if (!ns) @@ -225,6 +235,68 @@ function escapeProcessingInstructionContent(rawContent) { : rawContent; } +var foreignContextCache = new WeakMap(); + +function cacheForeignContext(node, stop, foreign, clock) { + for (; node && node !== stop; node = node.parentNode) { + foreignContextCache.set(node, { + clock: clock, + document: node.ownerDocument, + foreign: foreign + }); + } + return foreign; +} + +function isInForeignContent(parent, kid) { + if (parent.nodeType === 0 && kid.parentNode) + parent = kid.parentNode; + + var clock = parent.rooted && parent.ownerDocument.modclock; + var start = clock ? parent : null; + var childName = ''; + var root = null; + while (parent && parent.nodeType === 1 /*ELEMENT_NODE*/) { + var cached = start && foreignContextCache.get(parent); + if (cached && cached.document === parent.ownerDocument && + cached.clock === clock) + return cacheForeignContext(start, parent, cached.foreign, clock); + + root = parent; + var name = utils.toASCIILowerCase(parent.localName); + + if (name === 'svg' || name === 'math') + return cacheForeignContext(start, parent, true, clock); + if (name === 'foreignobject' || name === 'desc' || name === 'title') + return cacheForeignContext(start, parent, false, clock); + if (name === 'mi' || name === 'mo' || name === 'mn' || + name === 'ms' || name === 'mtext') + return cacheForeignContext(start, parent, + childName === 'mglyph' || childName === 'malignmark', clock); + if (name === 'annotation-xml') { + start = null; + for (var i = 0; i < parent._numattrs; i++) { + var attribute = parent._attr(i); + if (utils.toASCIILowerCase(attrname(attribute)) === 'encoding') { + var encoding = attribute.value && + utils.toASCIILowerCase(attribute.value); + if (encoding === 'text/html' || + encoding === 'application/xhtml+xml') + return cacheForeignContext(start, parent, false, clock); + break; + } + } + } + + childName = name; + parent = parent.parentNode; + } + + return cacheForeignContext(start, parent, !!root && + (root.namespaceURI === NAMESPACE.SVG || + root.namespaceURI === NAMESPACE.MATHML), clock); +} + function serializeOne(kid, parent) { var s = ''; switch(kid.nodeType) { @@ -243,11 +315,15 @@ function serializeOne(kid, parent) { s += '>'; if (!(html && emptyElements[tagname])) { - var ss = kid.serialize(); + var upperTag = tagname.toUpperCase(); // If an element can have raw content, this content may // potentially require escaping to avoid XSS. - var upperTag = tagname.toUpperCase(); - if (hasRawContent[upperTag] && !hasRawContentFallback[upperTag]) { + var nonFallbackRawContent = hasRawContent[upperTag] && + !hasRawContentFallback[upperTag]; + var escapeRawText = html && nonFallbackRawContent && !kid._innerHTML && + isInForeignContent(parent, kid); + var ss = escapeRawText ? serializeForeignRawText(kid) : kid.serialize(); + if (!escapeRawText && nonFallbackRawContent) { 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 ce6e524..a80413c 100644 --- a/test/xss.js +++ b/test/xss.js @@ -105,6 +105,197 @@ exports.fp170_37 = function () { ); }; +exports.htmlStyleTextMovedIntoForeignContentIsEscaped = function () { + const SVG = 'http://www.w3.org/2000/svg'; + const payload = + '--literal:"©\u00A0";/**/'; + const document = domino.createDocument(''); + const svg = document.createElementNS(SVG, 'svg'); + const group = document.createElementNS(SVG, 'g'); + const wrapper = document.createElement('g'); + const style = document.createElement('style'); + const marker = document.createElement('span'); + style.textContent = payload; + marker.textContent = 'kept'; + style.appendChild(marker); + wrapper.appendChild(style); + svg.appendChild(group); + document.body.appendChild(svg); + document.body.appendChild(wrapper); + document.serialize().should.containEql('', + ).cloneNode(true); + const style = document.querySelector('style'); + const wrapper = style.parentNode; + style.textContent = payload; + document.modclock.should.equal(0); + style.outerHTML.should.containEql(''); + document.querySelector('svg g').appendChild(wrapper); + + const html = document.serialize(); + html.should.containEql( + '', + ); + return alertFired(html).should.eventually.be.false('alert fired for: ' + html); +}; + +const HTML_ELEMENT = null; +const SVG = 'http://www.w3.org/2000/svg'; +const MATHML = 'http://www.w3.org/1998/Math/MathML'; +const styleCss = + '@media (width < 600px) { svg > g { --label: "&"; } }' + + '/**/'; +const serializedStyleCss = + '@media (width < 600px) { svg > g { --label: "&"; } }' + + '/*<img><script>alert(1)</script>*/'; + +function createStyleDocument(ancestors) { + const document = domino.createDocument(''); + let parent = document.body; + for (const [namespace, name, attributes = {}] of ancestors) { + const element = namespace ? + document.createElementNS(namespace, name) : document.createElement(name); + for (const attribute in attributes) { + element.setAttribute(attribute, attributes[attribute]); + } + parent.appendChild(element); + parent = element; + } + + const style = document.createElement('style'); + style.textContent = styleCss; + parent.appendChild(style); + return document; +} + +function assertStyleSerialization(ancestors, expected) { + const document = createStyleDocument(ancestors); + document.body.serialize().should.containEql( + '', + ); + return document; +} + +exports.rawContentIsEscapedInForeignContent = function () { + const cases = [ + [[SVG, 'svg']], + [[SVG, 'svg'], [HTML_ELEMENT, 'g']], + [[SVG, 'svg'], [SVG, 'foreignObject'], [HTML_ELEMENT, 'svg']], + [[MATHML, 'math'], [HTML_ELEMENT, 'mtext'], [HTML_ELEMENT, 'mglyph']], + [[MATHML, 'math'], + [MATHML, 'annotation-xml', { encoding: 'application/xml' }]], + ]; + for (const ancestors of cases) { + assertStyleSerialization(ancestors, serializedStyleCss); + } +}; + +exports.rawContentRemainsRawAtHtmlIntegrationPoints = function () { + const cases = [ + [[SVG, 'svg'], [SVG, 'foreignObject'], [HTML_ELEMENT, 'div']], + [[SVG, 'svg'], [SVG, 'foreignObject'], [SVG, 'a']], + [[MATHML, 'math'], [HTML_ELEMENT, 'mtext']], + ]; + for (const ancestors of cases) { + assertStyleSerialization(ancestors, styleCss); + } +}; + +exports.annotationXmlEncodingChangeUpdatesRawContentContext = function () { + const document = assertStyleSerialization( + [[MATHML, 'math'], + [MATHML, 'annotation-xml', { ENCODING: 'TEXT/HTML' }], + [HTML_ELEMENT, 'div']], + styleCss, + ); + document.querySelector('annotation-xml') + .setAttribute('ENCODING', 'application/xml'); + document.body.serialize().should.containEql( + '', + ); +}; + +exports.detachedRawContentUsesSerializedRootContext = function () { + for (const [namespace, name] of [ + [SVG, 'g'], + [HTML_ELEMENT, 'svg'], + ['http://www.w3.org/1999/xhtml', 'SvG'], + ]) { + const document = domino.createDocument(''); + const root = namespace ? + document.createElementNS(namespace, name) : document.createElement(name); + const style = document.createElement('style'); + style.textContent = styleCss; + root.appendChild(style); + root.outerHTML + .should.containEql(''); + } +}; + +exports.serializedForeignStylePreservesCssSemantics = async function () { + const page = await browser.newPage(); + await page.setContent( + '', + ); + (await page.$eval('style', (element) => element.textContent)) + .should.equal(styleCss); + await page.close(); +}; + +exports.plaintextInForeignObjectKeepsParserFixture = function () { + const document = domino.createDocument( + '
foo
' + + '</foreignObject></svg><div>bar</div>', + ); + document.serialize().should.equal( + '<html><head></head><body>' + + '<svg><foreignObject><div>foo</div><plaintext>' + + '</foreignObject></svg><div>bar</div></plaintext>' + + '</foreignObject></svg></body></html>', + ); + document.body.innerHTML.should.equal( + '<svg><foreignObject><div>foo</div><plaintext>' + + '</foreignObject></svg><div>bar</div></plaintext>' + + '</foreignObject></svg>', + ); +}; + exports.escapeAngleBracketsInDivAttr = function () { var document = domino.createDocument( `<div>You don't have JS! Click<a href="#" title="Search for </div><script>alert(1)</script> without JS">here</a> to go to the no-js website.</div>`,