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(
+ '