diff --git a/lib/NodeUtils.js b/lib/NodeUtils.js index d4f2d9b..de3103d 100644 --- a/lib/NodeUtils.js +++ b/lib/NodeUtils.js @@ -121,6 +121,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) @@ -289,6 +299,169 @@ function escapeProcessingInstructionContent(rawContent) { : rawContent; } +var foreignContextCache = new WeakMap(); + +// Namespaces a re-parsing HTML parser would assign, derived from the ancestor +// chain. A parser reading our output only sees tag names, so `` +// puts `desc` in the MathML namespace while `` makes it an HTML +// integration point. Deciding by element name alone gets those two cases wrong. +var SVG_INTEGRATION_POINTS = { + foreignobject: true, + desc: true, + title: true +}; + +var MATHML_TEXT_INTEGRATION_POINTS = { + mi: true, + mo: true, + mn: true, + ms: true, + mtext: true +}; + +// Start tags that a parser treats as a parse error inside foreign content: it +// pops back out to HTML instead of nesting them. Everything below such a tag is +// therefore parsed as HTML, whatever the SVG/MathML ancestors say. +// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inforeign +var HTML_BREAKOUT_TAGS = { + b: true, big: true, blockquote: true, body: true, br: true, center: true, + code: true, dd: true, div: true, dl: true, dt: true, em: true, embed: true, + h1: true, h2: true, h3: true, h4: true, h5: true, h6: true, head: true, + hr: true, i: true, img: true, li: true, listing: true, menu: true, + meta: true, nobr: true, ol: true, p: true, pre: true, ruby: true, s: true, + small: true, span: true, strong: true, strike: true, sub: true, sup: true, + table: true, tt: true, u: true, ul: true, var: true +}; + +// `font` only breaks out when it carries one of these attributes. +function isBreakoutFont(element) { + for (var i = 0; i < element._numattrs; i++) { + var name = utils.toASCIILowerCase(attrname(element._attr(i))); + if (name === 'color' || name === 'face' || name === 'size') return true; + } + return false; +} + +function breaksOutOfForeignContent(element, name) { + return HTML_BREAKOUT_TAGS[name] === true || + (name === 'font' && isBreakoutFont(element)); +} + +function isHtmlAnnotationXml(element) { + for (var i = 0; i < element._numattrs; i++) { + var attribute = element._attr(i); + if (utils.toASCIILowerCase(attrname(attribute)) === 'encoding') { + var encoding = attribute.value && utils.toASCIILowerCase(attribute.value); + return encoding === 'text/html' || encoding === 'application/xhtml+xml'; + } + } + return false; +} + +// The namespace that `childName` is parsed in when it appears inside `element`, +// given that `element` itself is parsed in `parentNamespace`. +function childNamespace(element, parentNamespace, childName) { + var name = utils.toASCIILowerCase(serializedTagName(element) || ''); + + if (parentNamespace === NAMESPACE.SVG) { + // Integration points are per-namespace: `desc`/`title` open an HTML island + // inside SVG only. + if (SVG_INTEGRATION_POINTS[name] || breaksOutOfForeignContent(element, name)) + return NAMESPACE.HTML; + return NAMESPACE.SVG; + } + + if (parentNamespace === NAMESPACE.MATHML) { + if (MATHML_TEXT_INTEGRATION_POINTS[name]) { + // `mglyph` and `malignmark` stay in MathML even inside a text + // integration point. + return (childName === 'mglyph' || childName === 'malignmark') ? + NAMESPACE.MATHML : NAMESPACE.HTML; + } + if (name === 'annotation-xml') + return isHtmlAnnotationXml(element) ? NAMESPACE.HTML : NAMESPACE.MATHML; + return breaksOutOfForeignContent(element, name) ? + NAMESPACE.HTML : NAMESPACE.MATHML; + } + + if (name === 'svg') return NAMESPACE.SVG; + if (name === 'math') return NAMESPACE.MATHML; + return NAMESPACE.HTML; +} + +// Resolves the namespace a re-parsing parser would put `kid` in. Ancestors are +// collected upwards only until a cached one is found, then resolved downwards, +// so repeated serialization of siblings costs one step instead of a full walk. +// The cache stores the namespace an element itself is parsed in, which -- unlike +// the namespace of its children -- does not depend on which child is serialized. +function serializationNamespace(parent, kid) { + if (parent.nodeType === 0 && kid.parentNode) + parent = kid.parentNode; + + var clock = parent.rooted && parent.ownerDocument.modclock; + var chain = []; + var ns = null; + + for (var node = parent; node;) { + if (node.nodeType === 1 /*ELEMENT_NODE*/) { + var cached = clock && foreignContextCache.get(node); + chain.push(node); + if (cached && cached.document === node.ownerDocument && + cached.clock === clock) { + // The cached value is the namespace of this element itself, so the + // downward pass still has to run its own step. + ns = cached.namespace; + break; + } + node = node.parentNode; + } else if (node.nodeType === 11 /*DOCUMENT_FRAGMENT_NODE*/ && node._host) { + node = node._host; + } else { + node = node.parentNode; + } + } + + if (chain.length === 0) return ns === null ? NAMESPACE.HTML : ns; + + if (ns === null) { + // No cached ancestor: a detached subtree is serialized on its own, so its + // root carries the namespace a parser would have inferred from ancestors + // we cannot see. + var root = chain[chain.length - 1]; + ns = (root.namespaceURI === NAMESPACE.SVG || + root.namespaceURI === NAMESPACE.MATHML) ? root.namespaceURI : + NAMESPACE.HTML; + } + + for (var index = chain.length - 1, cacheable = !!clock; index >= 0; index--) { + var element = chain[index]; + if (cacheable) { + foreignContextCache.set(element, { + clock: clock, + document: element.ownerDocument, + namespace: ns + }); + } + // Below an `annotation-xml` the namespace depends on its `encoding` + // attribute, and attribute edits do not bump the document's mod clock, so + // those descendants must not be cached. + if (ns === NAMESPACE.MATHML && + utils.toASCIILowerCase(serializedTagName(element) || '') === + 'annotation-xml') + cacheable = false; + var childName = index > 0 ? + utils.toASCIILowerCase(serializedTagName(chain[index - 1]) || '') : + utils.toASCIILowerCase(serializedTagName(kid) || ''); + ns = childNamespace(element, ns, childName); + } + + return ns; +} + +function isInForeignContent(parent, kid) { + return serializationNamespace(parent, kid) !== NAMESPACE.HTML; +} + function serializeOne(kid, parent) { var s = ''; switch(kid.nodeType) { @@ -307,11 +480,20 @@ 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] && ss.includes('` can only come from already-escaped data. + if (nonFallbackRawContent && ss.includes('*/'; + 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('kept'); + return alertFired(html).should.eventually.be.false('alert fired for: ' + html); +}; + +exports.foreignContextCacheDoesNotCrossDocuments = function () { + const SVG = 'http://www.w3.org/2000/svg'; + const payload = '{"x":""}'; + const first = domino.createDocument(''); + const wrapper = first.createElement('a'); + const script = first.createElement('script'); + script.type = 'application/json'; + script.textContent = payload; + wrapper.appendChild(script); + first.body.appendChild(wrapper); + first.serialize().should.containEql(payload); + + const second = domino.createDocument(''); + const svg = second.createElementNS(SVG, 'svg'); + second.body.appendChild(svg); + svg.appendChild(wrapper); + + const html = second.serialize(); + html.should.containEql('{"x":"<img src=x onerror=alert(42)>"}'); + return alertFired(html).should.eventually.be.false('alert fired for: ' + html); +}; + +exports.foreignContextCacheSkipsUntrackedDocuments = function () { + const payload = ''; + const document = domino.createDocument( + '
', + ).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 () { + // `g` rather than `div`: `div` is one of the start tags that break a parser + // out of foreign content on its own, which would mask what this test checks. + const document = assertStyleSerialization( + [[MATHML, 'math'], + [MATHML, 'annotation-xml', { ENCODING: 'TEXT/HTML' }], + [HTML_ELEMENT, 'g']], + styleCss, + ); + document.querySelector('annotation-xml') + .setAttribute('ENCODING', 'application/xml'); + document.body.serialize().should.containEql( + '', + ); +}; + +exports.integrationPointNamesOnlyApplyInTheirOwnNamespace = function () { + // `desc`, `title` and `foreignObject` are HTML integration points inside SVG + // only; `mi`/`mo`/`mn`/`ms`/`mtext` and `annotation-xml` inside MathML only. + // A parser re-reading our output derives the namespace from the ancestor + // chain, so matching these names without checking the surrounding namespace + // leaves the raw text live. + const cases = [ + [[MATHML, 'math'], [HTML_ELEMENT, 'desc']], + [[MATHML, 'math'], [HTML_ELEMENT, 'title']], + [[MATHML, 'math'], [HTML_ELEMENT, 'foreignObject']], + [[SVG, 'svg'], [HTML_ELEMENT, 'mtext']], + [[SVG, 'svg'], [HTML_ELEMENT, 'mi']], + [[SVG, 'svg'], + [HTML_ELEMENT, 'annotation-xml', { encoding: 'text/html' }]], + [[SVG, 'svg'], [SVG, 'foreignObject'], [MATHML, 'math'], + [HTML_ELEMENT, 'desc']], + ]; + for (const ancestors of cases) { + assertStyleSerialization(ancestors, serializedStyleCss); + } +}; + +exports.integrationPointNamesInTheirOwnNamespaceStayRaw = function () { + // The mirror image of the test above: the same names in the namespace they + // belong to keep raw CSS, so escaping does not break stylesheets. + const cases = [ + [[SVG, 'svg'], [SVG, 'desc']], + [[SVG, 'svg'], [HTML_ELEMENT, 'title']], + [[MATHML, 'math'], [HTML_ELEMENT, 'mtext']], + [[SVG, 'svg'], [SVG, 'foreignObject'], [MATHML, 'math'], + [HTML_ELEMENT, 'mtext']], + // math -> mtext opens HTML, re-enters SVG, desc opens HTML again. + [[MATHML, 'math'], [HTML_ELEMENT, 'mtext'], [SVG, 'svg'], + [HTML_ELEMENT, 'desc']], + ]; + for (const ancestors of cases) { + assertStyleSerialization(ancestors, styleCss); + } +}; + +exports.htmlBreakoutTagsLeaveForeignContent = function () { + // A parser treats `div`, `p`, `span`, `table`... inside SVG/MathML as a parse + // error and pops back out to HTML, so their descendants are HTML and raw + // text must stay raw -- escaping there would corrupt stylesheets. + for (const ancestors of [ + [[SVG, 'svg'], [HTML_ELEMENT, 'div']], + [[MATHML, 'math'], [HTML_ELEMENT, 'p']], + [[SVG, 'svg'], [HTML_ELEMENT, 'table']], + [[MATHML, 'math'], [MATHML, 'annotation-xml'], [HTML_ELEMENT, 'div']], + ]) { + assertStyleSerialization(ancestors, styleCss); + } + + // `font` only breaks out when it carries color/face/size. + assertStyleSerialization( + [[SVG, 'svg'], [HTML_ELEMENT, 'font']], serializedStyleCss); + assertStyleSerialization( + [[SVG, 'svg'], [HTML_ELEMENT, 'font', { color: 'red' }]], styleCss); + // `g` is not a breakout tag, so it stays in SVG. + assertStyleSerialization([[SVG, 'svg'], [HTML_ELEMENT, 'g']], + serializedStyleCss); +}; + +exports.foreignContentReentersAfterBreakoutTag = function () { + // svg -> div leaves foreign content, enters it again, and + // foreignObject is not an integration point in MathML. + const document = createStyleDocument( + [[SVG, 'svg'], [HTML_ELEMENT, 'div'], [MATHML, 'math'], + [HTML_ELEMENT, 'foreignObject']], + ); + const html = document.serialize(); + html.should.containEql(''); + return alertFired(html).should.eventually.be.false('alert fired for: ' + html); +}; + +exports.integrationPointNameInWrongNamespaceDoesNotFireAlert = function () { + const document = createStyleDocument( + [[MATHML, 'math'], [HTML_ELEMENT, 'desc']], + ); + const html = document.serialize(); + return alertFired(html).should.eventually.be.false('alert fired for: ' + html); +}; + +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>`,