From 67fbcc5d2ce98c7e4358917a34de3c59c3fca0e9 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:02:41 -0500 Subject: [PATCH 1/2] fix: escape raw text parsed as foreign content --- lib/NodeUtils.js | 82 +++++++++++++++++++- test/xss.js | 191 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 3 deletions(-) diff --git a/lib/NodeUtils.js b/lib/NodeUtils.js index d4f2d9b..fbc45a5 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,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) { @@ -307,11 +379,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] && 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('', + ).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>`, From f129ed4696e75b23943e89f3cba5a2aaeed41cb6 Mon Sep 17 00:00:00 2001 From: dkautomation23 <317500839+dkautomation23@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:00:29 +0300 Subject: [PATCH 2/2] fix: resolve foreign content namespace the way a parser re-reads it Decide whether raw-text content is emitted raw or escaped from the namespace a parser would assign when re-reading the serialized output, walking the ancestor chain top-down: - HTML integration points apply only inside their own namespace (desc/title/foreignObject in SVG; mi/mo/mn/ms/mtext and annotation-xml with an HTML encoding in MathML). - Start tags that break out of foreign content (div, p, span, table, font with color/face/size, ...) switch back to HTML. - The element's own closing tag is always escaped, since a breakout tag earlier in the same foreign container can return the parser to HTML. The cache stores each element's own namespace and stops at the first cached ancestor, so serialization cost stays in line with main. Builds on #33. --- lib/NodeUtils.js | 200 ++++++++++++++++++++++++++++++++++++----------- test/xss.js | 87 ++++++++++++++++++++- 2 files changed, 239 insertions(+), 48 deletions(-) diff --git a/lib/NodeUtils.js b/lib/NodeUtils.js index fbc45a5..de3103d 100644 --- a/lib/NodeUtils.js +++ b/lib/NodeUtils.js @@ -301,64 +301,165 @@ function escapeProcessingInstructionContent(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 - }); +// Namespaces a re-parsing HTML parser would assign, derived from the ancestor +// chain. A parser reading our output only sees tag names, so `<math><desc>` +// puts `desc` in the MathML namespace while `<svg><desc>` 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 foreign; + return false; } -function isInForeignContent(parent, kid) { +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 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; - } + 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; - childName = name; - parent = parent.parentNode; + 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; } - return cacheForeignContext(start, parent, !!root && - (root.namespaceURI === NAMESPACE.SVG || - root.namespaceURI === NAMESPACE.MATHML), clock); + 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) { @@ -387,7 +488,12 @@ function serializeOne(kid, parent) { var escapeRawText = html && nonFallbackRawContent && !kid._innerHTML && isInForeignContent(parent, kid); var ss = escapeRawText ? serializeForeignRawText(kid) : kid.serialize(); - if (!escapeRawText && nonFallbackRawContent && ss.includes('</')) { + // Escape the element's own closing tag even when we believe we are in + // foreign content: a breakout tag earlier in the same foreign container + // pops the parser back to HTML, and then this element *is* raw text for + // it. Escaping here costs nothing when we were right -- in foreign + // content a literal `</tag>` can only come from already-escaped data. + if (nonFallbackRawContent && ss.includes('</')) { ss = escapeMatchingClosingTag(ss, tagname); const fallbackTags = fallbackRawContentTags(parent); for (const fallbackTag of fallbackTags) { diff --git a/test/xss.js b/test/xss.js index cf70781..13d3305 100644 --- a/test/xss.js +++ b/test/xss.js @@ -238,10 +238,12 @@ exports.rawContentRemainsRawAtHtmlIntegrationPoints = function () { }; 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, 'div']], + [HTML_ELEMENT, 'g']], styleCss, ); document.querySelector('annotation-xml') @@ -251,6 +253,89 @@ exports.annotationXmlEncodingChangeUpdatesRawContentContext = function () { ); }; +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, <svg> 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, <math> 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('<style>' + serializedStyleCss + '</style>'); + 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'],