diff --git a/lib/NodeUtils.js b/lib/NodeUtils.js
index a80490b..af01180 100644
--- a/lib/NodeUtils.js
+++ b/lib/NodeUtils.js
@@ -123,6 +123,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)
@@ -291,6 +301,255 @@ function escapeProcessingInstructionContent(rawContent) {
: rawContent;
}
+// The contexts in which a parser that re-reads our output may process a start
+// tag. A parser assigns namespaces from the markup, not from our tree, and an
+// element is resolved knowing only its ancestors, so it is resolved against
+// every context it may be parsed in. Raw text stays raw only when none of them
+// is foreign content.
+// https://html.spec.whatwg.org/multipage/parsing.html#tree-construction
+var HTML_CONTEXT = 1; // HTML rules, including at HTML integration points
+var MATHML_TEXT_CONTEXT = 2; // in mi, mo, mn, ms or mtext
+var SVG_CONTEXT = 4;
+var MATHML_CONTEXT = 8;
+var ANNOTATION_XML_CONTEXT = 16; // in annotation-xml without an HTML encoding
+var HTML_RULES = HTML_CONTEXT | MATHML_TEXT_CONTEXT;
+var FOREIGN_CONTEXTS = SVG_CONTEXT | MATHML_CONTEXT | ANNOTATION_XML_CONTEXT;
+// Where the element is rather than how it is parsed: inside an svg or math
+// element, inside one that sits in an integration point of another, inside
+// the content of a MathML text integration point (which a parser can pop back
+// to, and then reads mglyph and malignmark as MathML), and inside the HTML
+// content of any integration point (where the integration point itself can be
+// the current node, so an end tag is matched against its foreign ancestors).
+var IN_FOREIGN_CONTENT = 32;
+var IN_NESTED_FOREIGN_CONTENT = 64;
+var IN_MATHML_TEXT_CONTENT = 128;
+var IN_INTEGRATION_POINT_CONTENT = 256;
+// Where a parser may be in a table insertion mode inside foreign content, and
+// inside an svg or math element that it foster parents there, whose
+// integration points it then parses in that insertion mode.
+var IN_TABLE_CONTENT = 512;
+var IN_FOSTER_PARENTED_FOREIGN_CONTENT = 1024;
+var INHERITED_FLAGS = IN_FOREIGN_CONTENT | IN_NESTED_FOREIGN_CONTENT |
+ IN_MATHML_TEXT_CONTENT | IN_TABLE_CONTENT |
+ IN_FOSTER_PARENTED_FOREIGN_CONTENT;
+
+// Start tags that pop the parser out of foreign content.
+// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inforeign
+var foreignContentBreakoutTags = {
+ 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
+};
+
+// Elements whose content a parser reads as text up to their own end tag, which
+// a same-named element in it therefore ends early. The other raw text elements
+// get that end tag escaped in all of their content.
+var textEndedByOwnEndTag = {
+ iframe: true, noembed: true, noframes: true, noscript: true, textarea: true,
+ title: true
+};
+
+// Elements whose content a parser reads in a table insertion mode (true) or
+// in one where a table start tag is not special (false). In a table insertion
+// mode, a table start tag pops elements up to the table, whatever integration
+// points are on the way, and so the table too. The other table parts keep the
+// insertion mode as it is, or are ignored outside of a table.
+var tableInsertionModes = {
+ table: true, td: false, th: false, caption: false, template: false
+};
+
+// `serializedTagName()` in ASCII lowercase, as a parser reads it. Reads
+// `_localName` directly: `localName` is a megamorphic getter here.
+function lowerCaseTagName(element) {
+ var ns = element.namespaceURI;
+ var name = ns === NAMESPACE.HTML || ns === NAMESPACE.SVG ||
+ ns === NAMESPACE.MATHML ? element._localName : element.tagName;
+ for (var i = 0; i < name.length; i++) {
+ var c = name.charCodeAt(i);
+ if (c >= 65 && c <= 90) return utils.toASCIILowerCase(name);
+ }
+ return name;
+}
+
+// ASCII case-insensitive `svg` or `math`, without allocating.
+function isSvgOrMathName(name) {
+ if (name.length === 3) {
+ return (name.charCodeAt(0) | 32) === 115 &&
+ (name.charCodeAt(1) | 32) === 118 && (name.charCodeAt(2) | 32) === 103;
+ }
+ if (name.length === 4) {
+ return (name.charCodeAt(0) | 32) === 109 &&
+ (name.charCodeAt(1) | 32) === 97 && (name.charCodeAt(2) | 32) === 116 &&
+ (name.charCodeAt(3) | 32) === 104;
+ }
+ return false;
+}
+
+function attributeValue(element, name) {
+ for (var i = 0; i < element._numattrs; i++) {
+ var attribute = element._attr(i);
+ if (utils.toASCIILowerCase(attrname(attribute)) === name)
+ return attribute.value;
+ }
+ return null;
+}
+
+function breaksOutOfForeignContent(element, name) {
+ return foreignContentBreakoutTags[name] === true || (name === 'font' &&
+ (attributeValue(element, 'color') !== null ||
+ attributeValue(element, 'face') !== null ||
+ attributeValue(element, 'size') !== null));
+}
+
+function mathmlChildContexts(element, name) {
+ if (name === 'mi' || name === 'mo' || name === 'mn' || name === 'ms' ||
+ name === 'mtext') {
+ return MATHML_TEXT_CONTEXT | IN_MATHML_TEXT_CONTENT |
+ IN_INTEGRATION_POINT_CONTENT;
+ }
+ if (name === 'annotation-xml') {
+ var encoding = attributeValue(element, 'encoding');
+ encoding = encoding && utils.toASCIILowerCase(encoding);
+ return encoding === 'text/html' || encoding === 'application/xhtml+xml' ?
+ HTML_CONTEXT | IN_INTEGRATION_POINT_CONTENT : ANNOTATION_XML_CONTEXT;
+ }
+ return MATHML_CONTEXT;
+}
+
+// The contexts in which the children of `element` are parsed, when `element`
+// itself is parsed in one of `contexts`. `name` is `lowerCaseTagName(element)`
+// if the caller already has it.
+function childContexts(element, contexts, name) {
+ // Fast path: in HTML, only `svg` and `math` change the context.
+ if (contexts === HTML_CONTEXT && !isSvgOrMathName(element._localName))
+ return HTML_CONTEXT;
+
+ if (name === undefined) name = lowerCaseTagName(element);
+ var result = contexts & INHERITED_FLAGS;
+ if (contexts & FOREIGN_CONTEXTS) {
+ result |= IN_FOREIGN_CONTENT;
+ if (breaksOutOfForeignContent(element, name)) {
+ contexts |= HTML_CONTEXT;
+ } else {
+ if (contexts & SVG_CONTEXT) {
+ result |= name === 'foreignobject' || name === 'desc' ||
+ name === 'title' ? HTML_CONTEXT | IN_INTEGRATION_POINT_CONTENT :
+ SVG_CONTEXT;
+ }
+ if (contexts & MATHML_CONTEXT)
+ result |= mathmlChildContexts(element, name);
+ if (contexts & ANNOTATION_XML_CONTEXT) {
+ result |= name === 'svg' ? SVG_CONTEXT :
+ mathmlChildContexts(element, name);
+ }
+ // An earlier sibling may have popped the parser out of foreign content.
+ contexts |= HTML_RULES;
+ }
+ }
+
+ var mathmlGlyph = name === 'mglyph' || name === 'malignmark';
+ if (contexts & MATHML_TEXT_CONTEXT && mathmlGlyph)
+ result |= MATHML_CONTEXT;
+ if (contexts & HTML_CONTEXT ||
+ (contexts & MATHML_TEXT_CONTEXT && !mathmlGlyph)) {
+ if (name === 'svg' || name === 'math') {
+ result |= (name === 'svg' ? SVG_CONTEXT : MATHML_CONTEXT) |
+ IN_FOREIGN_CONTENT;
+ if (contexts & IN_FOREIGN_CONTENT) result |= IN_NESTED_FOREIGN_CONTENT;
+ if (contexts & IN_TABLE_CONTENT)
+ result |= IN_FOSTER_PARENTED_FOREIGN_CONTENT;
+ } else {
+ result |= HTML_CONTEXT | (contexts & IN_INTEGRATION_POINT_CONTENT);
+ // Only an element that is surely HTML leaves a table insertion mode: as
+ // foreign content, it would leave the insertion mode as it is.
+ var tableInsertionMode = tableInsertionModes[name];
+ if (tableInsertionMode === true)
+ result |= IN_TABLE_CONTENT;
+ else if (tableInsertionMode === false && !(contexts & FOREIGN_CONTEXTS))
+ result &= ~IN_TABLE_CONTENT;
+ // The parser can pop back to a MathML text integration point first.
+ if (mathmlGlyph && contexts & IN_MATHML_TEXT_CONTENT)
+ result |= MATHML_CONTEXT;
+ }
+ }
+ return result;
+}
+
+// Whether a parser may stop following our tree at `element`'s start tag
+// inside foreign content, so that an end tag we serialize later closes a
+// foreign element around it. In the HTML content of an integration point the
+// parser pops, ignores or ends elements early on its own, and then matches
+// their end tags against the foreign elements around the integration point,
+// which `foreignNames` counts by name. It also pops past an integration point
+// of a foster parented svg or math element at a table start tag. At a
+// breakout tag inside an svg or math element nested in an integration point,
+// it pops that element back to it.
+function misparses(element, contexts, name, foreignNames) {
+ if (contexts & IN_INTEGRATION_POINT_CONTENT && contexts & HTML_RULES) {
+ if (foreignNames[name] > 0 && name !== 'svg' && name !== 'math')
+ return true;
+ if (name === 'table' && contexts & IN_TABLE_CONTENT &&
+ contexts & IN_FOSTER_PARENTED_FOREIGN_CONTENT)
+ return true;
+ }
+ // A void element has no end tag, but in foreign content it is not void, so
+ // it stays open around what we serialize after it.
+ if (contexts & FOREIGN_CONTEXTS && element.namespaceURI === NAMESPACE.HTML &&
+ emptyElements[element._localName] === true &&
+ !breaksOutOfForeignContent(element, name))
+ return true;
+ return (contexts & IN_NESTED_FOREIGN_CONTENT) !== 0 &&
+ (contexts & FOREIGN_CONTEXTS) !== 0 &&
+ breaksOutOfForeignContent(element, name);
+}
+
+// The contexts in which `kid` is parsed as a child of `parent`. Counts the
+// names of the foreign elements around `kid` into `foreignNames`, if given.
+function childContextsOf(parent, kid, foreignNames) {
+ if (parent.nodeType === 0)
+ parent = kid.parentNode;
+
+ var ancestors = [];
+ var contexts = 0;
+ for (var node = parent; node;) {
+ if (node.nodeType === 1 /*ELEMENT_NODE*/) {
+ ancestors.push(node);
+ node = node.parentNode;
+ } else if (node.nodeType === 11 /*DOCUMENT_FRAGMENT_NODE*/ && node._host) {
+ node = node._host;
+ } else {
+ if (node.nodeType === 9 /*DOCUMENT_NODE*/) contexts = HTML_CONTEXT;
+ break;
+ }
+ }
+
+ // A detached subtree is parsed in whatever namespace its root ends up in.
+ var root = ancestors.length ? ancestors[ancestors.length - 1] : kid;
+ if (contexts === 0) {
+ contexts = root.namespaceURI === NAMESPACE.SVG ? SVG_CONTEXT :
+ root.namespaceURI === NAMESPACE.MATHML ? MATHML_CONTEXT : HTML_CONTEXT;
+ }
+ for (var i = ancestors.length - 1; i >= 0; i--) {
+ var name = lowerCaseTagName(ancestors[i]);
+ if (foreignNames !== undefined && contexts & FOREIGN_CONTEXTS)
+ foreignNames[name] = (foreignNames[name] || 0) + 1;
+ contexts = childContexts(ancestors[i], contexts, name);
+ }
+ if (contexts & FOREIGN_CONTEXTS) contexts |= IN_FOREIGN_CONTENT;
+ return contexts;
+}
+
+// Raw text of an HTML raw-text element inside foreign content is parsed as
+// normal text, so it has to be escaped when serialized.
+function isRawTextElement(kid) {
+ return kid.namespaceURI === NAMESPACE.HTML && !kid._innerHTML &&
+ hasRawContent[kid.localName.toUpperCase()] === true;
+}
+
function serializeChildren(parent, serialize) {
if (parent._innerHTML) return parent._innerHTML;
var kid = parent.firstChild;
@@ -300,6 +559,10 @@ function serializeChildren(parent, serialize) {
if (templateSerialize === undefined) {
templateSerialize = require('./htmlelts').elements.HTMLTemplateElement.prototype.serialize;
}
+ var foreignNames = Object.create(null);
+ var contexts = childContextsOf(parent, kid, foreignNames);
+ if (contexts !== HTML_CONTEXT)
+ return serializeForeignContent(parent, serialize, contexts, foreignNames);
var stack = [];
var s = '';
while (true) {
@@ -313,13 +576,98 @@ function serializeChildren(parent, serialize) {
}
var kidSerialize = kid.nodeType === 1 ? kid.serialize : undefined;
+ if (kid.nodeType === 1 &&
+ (kidSerialize === serialize ||
+ (kidSerialize === templateSerialize && kid.content.serialize === serialize))) {
+ var content = kidSerialize === templateSerialize ? kid.content : kid;
+ var tagName = content._innerHTML || content.firstChild === null ?
+ undefined : serializedTagName(kid);
+ // Outside of foreign content, only `svg` and `math` change how a parser
+ // reads the children.
+ if (tagName !== undefined && isSvgOrMathName(tagName)) {
+ s += serializeOne(kid, parent, serializeForeignContent(kid, serialize,
+ childContexts(kid, HTML_CONTEXT), Object.create(null)));
+ kid = kid.nextSibling;
+ continue;
+ }
+ if (tagName !== undefined &&
+ !(kid.namespaceURI === NAMESPACE.HTML && emptyElements[tagName])) {
+ stack.push({kid: kid, parent: parent, output: s});
+ s = '';
+ parent = content;
+ kid = content.firstChild;
+ continue;
+ }
+ s += serializeOne(kid, parent, content._innerHTML || '');
+ }
+ else {
+ s += serializeOne(kid, parent);
+ }
+ kid = kid.nextSibling;
+ }
+}
+
+// Serializes the children of `parent` like `serializeChildren()`, when a
+// parser re-reading our output parses them in `contexts`, inside foreign
+// content. The contexts are kept up to date top-down, so that raw text needs
+// no ancestor walk of its own, and `foreignNames` counts the foreign elements
+// open around the children by name.
+function serializeForeignContent(parent, serialize, contexts, foreignNames) {
+ var kid = parent.firstChild;
+ var stack = [];
+ var s = '';
+ // Whether the text children of `parent` are escaped as foreign raw text.
+ var escapeText = false;
+ // Set once the parser may stop following the tree, for the rest of it.
+ var misparsed = false;
+ // The outermost element around the children of `parent` that a parser may
+ // read as `textEndedByOwnEndTag` text.
+ var rawTextName;
+ while (true) {
+ if (kid === null) {
+ if (stack.length === 0) return s;
+ var frame = stack.pop();
+ s = frame.output +
+ serializeOne(frame.kid, frame.parent, s, frame.foreign);
+ if (frame.foreignName !== undefined) foreignNames[frame.foreignName]--;
+ contexts = frame.contexts;
+ escapeText = frame.escapeText;
+ rawTextName = frame.rawTextName;
+ parent = frame.parent;
+ kid = frame.kid.nextSibling;
+ continue;
+ }
+
+ var kidSerialize = undefined;
+ var kidName = undefined;
+ if (kid.nodeType === 1) {
+ kidSerialize = kid.serialize;
+ kidName = lowerCaseTagName(kid);
+ // A same-named element ends that text early, which makes the end tags
+ // of what follows in it stray.
+ if (!misparsed) {
+ misparsed = kidName === rawTextName ||
+ misparses(kid, contexts, kidName, foreignNames);
+ }
+ }
if (kid.nodeType === 1 &&
(kidSerialize === serialize ||
(kidSerialize === templateSerialize && kid.content.serialize === serialize))) {
var content = kidSerialize === templateSerialize ? kid.content : kid;
if (!content._innerHTML && content.firstChild !== null &&
!(kid.namespaceURI === NAMESPACE.HTML && emptyElements[serializedTagName(kid)])) {
- stack.push({kid: kid, parent: parent, output: s});
+ var foreignName = contexts & FOREIGN_CONTEXTS ? kidName : undefined;
+ var foreign = misparsed || (contexts & FOREIGN_CONTEXTS) !== 0;
+ stack.push({kid: kid, parent: parent, output: s, contexts: contexts,
+ escapeText: escapeText, foreignName: foreignName,
+ rawTextName: rawTextName, foreign: foreign});
+ if (foreignName !== undefined)
+ foreignNames[foreignName] = (foreignNames[foreignName] || 0) + 1;
+ if (rawTextName === undefined && contexts & HTML_RULES &&
+ textEndedByOwnEndTag[kidName] === true)
+ rawTextName = kidName;
+ escapeText = content === kid && foreign && isRawTextElement(kid);
+ contexts = childContexts(kid, contexts, kidName);
s = '';
parent = content;
kid = content.firstChild;
@@ -327,6 +675,10 @@ function serializeChildren(parent, serialize) {
}
s += serializeOne(kid, parent, content._innerHTML || '');
}
+ else if (escapeText && (kid.nodeType === 3 /*TEXT_NODE*/ ||
+ kid.nodeType === 4 /*CDATA_SECTION_NODE*/)) {
+ s += escape(kid.data);
+ }
else {
s += serializeOne(kid, parent);
}
@@ -334,7 +686,10 @@ function serializeChildren(parent, serialize) {
}
}
-function serializeOne(kid, parent, children) {
+// `children` and `foreign` are passed by the serialization loops, which have
+// already serialized (and, for foreign raw text, escaped) the children and
+// know whether a parser may read `kid` as foreign content.
+function serializeOne(kid, parent, children, foreign) {
var s = '';
switch(kid.nodeType) {
case 1: //ELEMENT_NODE
@@ -352,16 +707,26 @@ function serializeOne(kid, parent, children) {
s += '>';
if (!(html && emptyElements[tagname])) {
- var ss = children === undefined ? kid.serialize() : children;
+ var upperTag = tagname.toUpperCase();
+ var ss = children;
+ if (ss === undefined) {
+ foreign = hasRawContent[upperTag] === true && !kid._innerHTML &&
+ (childContextsOf(parent, kid) & FOREIGN_CONTEXTS) !== 0;
+ ss = foreign && html ? serializeForeignRawText(kid) : kid.serialize();
+ }
// 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('')) {
- ss = escapeMatchingClosingTag(ss, tagname);
+ var escaped = escapeMatchingClosingTag(ss, tagname);
const fallbackTags = fallbackRawContentTags(parent);
for (const fallbackTag of fallbackTags) {
- ss = escapeMatchingClosingTag(ss, fallbackTag);
+ escaped = escapeMatchingClosingTag(escaped, fallbackTag);
}
+ // Read as foreign content, the end tags that this escapes are real
+ // ones of nested elements; read as raw text, they would end it. Its
+ // text is escaped already, so escape all of its markup instead.
+ ss = foreign === true && escaped !== ss ?
+ ss.replace(/*/';
+ 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('';
+ document.body.serialize().should.containEql(serialized);
+ style.outerHTML.should.equal(serialized);
+ return document;
+}
+
+exports.rawContentIsEscapedOnlyWhereParsedAsForeignContent = function () {
+ const escaped = [
+ [[SVG, 'svg']],
+ [[SVG, 'svg'], [HTML_ELEMENT, 'g']],
+ [[SVG, 'svg'], [SVG, 'foreignObject'], [HTML_ELEMENT, 'svg']],
+ [[SVG, 'svg'], [HTML_ELEMENT, 'div'], [SVG, 'svg']],
+ [[MATHML, 'math'], [HTML_ELEMENT, 'mtext'], [HTML_ELEMENT, 'mglyph']],
+ [[MATHML, 'math'],
+ [MATHML, 'annotation-xml', { encoding: 'application/xml' }]],
+ // A parser only reads an integration point in its own namespace.
+ [[MATHML, 'math'], [SVG, 'foreignObject']],
+ [[MATHML, 'math'], [HTML_ELEMENT, 'title']],
+ [[SVG, 'svg'], [MATHML, 'mtext']],
+ [[SVG, 'svg'],
+ [HTML_ELEMENT, 'annotation-xml', { encoding: 'text/html' }]],
+ // Template contents are serialized in place.
+ [[SVG, 'svg'], [HTML_ELEMENT, 'template']],
+ [[MATHML, 'math'], [HTML_ELEMENT, 'template']],
+ ];
+ const raw = [
+ [[SVG, 'svg'], [SVG, 'foreignObject'], [HTML_ELEMENT, 'div']],
+ [[SVG, 'svg'], [SVG, 'foreignObject'], [SVG, 'a']],
+ [[MATHML, 'math'], [HTML_ELEMENT, 'mtext']],
+ [[MATHML, 'math'],
+ [MATHML, 'annotation-xml', { encoding: 'text/html' }]],
+ // `div` pops a parser out of foreign content.
+ [[MATHML, 'math'],
+ [MATHML, 'annotation-xml', { encoding: 'application/xml' }],
+ [HTML_ELEMENT, 'div']],
+ ];
+ for (const ancestors of escaped) {
+ assertStyleSerialization(ancestors, serializedStyleCss);
+ }
+ for (const ancestors of raw) {
+ assertStyleSerialization(ancestors, styleCss);
+ }
+};
+
+exports.rawContentIsEscapedOnceAParserMayLeaveTheTree = async function () {
+ const icon = el(SVG, 'svg', [el(SVG, 'g', [el(HTML_ELEMENT, 'span')])]);
+ const serialized = '';
+ const trees = [
+ // The pops a parser out of the icon back to the ,
+ // and the stray of the icon then closes the