Skip to content

Commit

Permalink
Merge pull request from GHSA-crh6-fp67-6883
Browse files Browse the repository at this point in the history
* fix: Report errors if DOM is not well-formed

In case such a DOM would be created, the part that is not well formed will be transformed into text nodes, in which xml specific characters like `<` and `>` are encoded accordingly.

This change can break your code, if you relied on this behavior, e.g. multiple root elements in the past.
We consider it more important to align with the specs that we want to be aligned with,
considering the potential security issues that might derive from people not being aware of the difference in behavior.

Resolves GHSA-crh6-fp67-6883
https://dom.spec.whatwg.org/#concept-node-ensure-pre-insertion-validity

* fix: Prevent setting documentElement

if node is not inserted and add missing `Document.ownerDocument`.
  • Loading branch information
karfau committed Oct 29, 2022
1 parent 7c96a72 commit 52a7083
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 35 deletions.
204 changes: 180 additions & 24 deletions lib/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,30 @@ NodeList.prototype = {
}
return buf.join('');
},
/**
* @private
* @param {function (Node):boolean} predicate
* @returns {Node | undefined}
*/
find: function (predicate) {
return Array.prototype.find.call(this, predicate);
},
/**
* @private
* @param {function (Node):boolean} predicate
* @returns {Node[]}
*/
filter: function (predicate) {
return Array.prototype.filter.call(this, predicate);
},
/**
* @private
* @param {Node} item
* @returns {number}
*/
indexOf: function (item) {
return Array.prototype.indexOf.call(this, item);
},
};

function LiveNodeList(node, refresh) {
Expand Down Expand Up @@ -455,7 +479,7 @@ DOMImplementation.prototype = {
doc.childNodes = new NodeList();
if (title !== false) {
doc.doctype = this.createDocumentType('html');
doc.doctype.ownerDocument = this;
doc.doctype.ownerDocument = doc;
doc.appendChild(doc.doctype);
var htmlNode = doc.createElement('html');
doc.appendChild(htmlNode);
Expand Down Expand Up @@ -636,6 +660,7 @@ function _visitNode(node, callback) {
*/
function Document(options) {
var opt = options || {};
this.ownerDocument = this;
/**
* The mime type of the document is determined at creation time and can not be modified.
*
Expand Down Expand Up @@ -739,47 +764,176 @@ function _removeChild(parentNode, child) {
_onUpdateChild(parentNode.ownerDocument, parentNode);
return child;
}

/**
* Returns `true` if `node` can be a parent for insertion.
* @param {Node} node
* @returns {boolean}
*/
function hasValidParentNodeType(node) {
return (
node &&
(node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE || node.nodeType === Node.ELEMENT_NODE)
);
}

/**
* Returns `true` if `node` can be inserted according to it's `nodeType`.
* @param {Node} node
* @returns {boolean}
*/
function hasInsertableNodeType(node) {
return (
node &&
(isElementNode(node) ||
isTextNode(node) ||
isDocTypeNode(node) ||
node.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
node.nodeType === Node.COMMENT_NODE ||
node.nodeType === Node.PROCESSING_INSTRUCTION_NODE)
);
}

/**
* preformance key(refChild == null)
* Returns true if `node` is a DOCTYPE node
* @param {Node} node
* @returns {boolean}
*/
function isDocTypeNode(node) {
return node && node.nodeType === Node.DOCUMENT_TYPE_NODE;
}

/**
* Returns true if the node is an element
* @param {Node} node
* @returns {boolean}
*/
function _insertBefore(parentNode, newChild, nextChild) {
var cp = newChild.parentNode;
function isElementNode(node) {
return node && node.nodeType === Node.ELEMENT_NODE;
}
/**
* Returns true if `node` is a text node
* @param {Node} node
* @returns {boolean}
*/
function isTextNode(node) {
return node && node.nodeType === Node.TEXT_NODE;
}

/**
* Check if en element node can be inserted before `child`, or at the end if child is falsy,
* according to the presence and position of a doctype node on the same level.
*
* @param {Document} doc The document node
* @param {Node} child the node that would become the nextSibling if the element would be inserted
* @returns {boolean} `true` if an element can be inserted before child
* @private
* https://dom.spec.whatwg.org/#concept-node-ensure-pre-insertion-validity
*/
function isElementInsertionPossible(doc, child) {
var parentChildNodes = doc.childNodes || [];
if (parentChildNodes.find(isElementNode) || isDocTypeNode(child)) {
return false;
}
var docTypeNode = parentChildNodes.find(isDocTypeNode);
return !(child && docTypeNode && parentChildNodes.indexOf(docTypeNode) > parentChildNodes.indexOf(child));
}
/**
* @private
* @param {Node} parent the parent node to insert `node` into
* @param {Node} node the node to insert
* @param {Node=} child the node that should become the `nextSibling` of `node`
* @returns {Node}
* @throws DOMException for several node combinations that would create a DOM that is not well-formed.
* @throws DOMException if `child` is provided but is not a child of `parent`.
* @see https://dom.spec.whatwg.org/#concept-node-ensure-pre-insertion-validity
*/
function _insertBefore(parent, node, child) {
if (!hasValidParentNodeType(parent)) {
throw new DOMException(HIERARCHY_REQUEST_ERR, 'Unexpected parent node type ' + parent.nodeType);
}
if (child && child.parentNode !== parent) {
throw new DOMException(NOT_FOUND_ERR, 'child not in parent');
}
if (
!hasInsertableNodeType(node) ||
// the sax parser currently adds top level text nodes, this will be fixed in 0.9.0
// || (node.nodeType === Node.TEXT_NODE && parent.nodeType === Node.DOCUMENT_NODE)
(isDocTypeNode(node) && parent.nodeType !== Node.DOCUMENT_NODE)
) {
throw new DOMException(
HIERARCHY_REQUEST_ERR,
'Unexpected node type ' + node.nodeType + ' for parent node type ' + parent.nodeType
);
}
var parentChildNodes = parent.childNodes || [];
var nodeChildNodes = node.childNodes || [];
if (parent.nodeType === Node.DOCUMENT_NODE) {
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
let nodeChildElements = nodeChildNodes.filter(isElementNode);
if (nodeChildElements.length > 1 || nodeChildNodes.find(isTextNode)) {
throw new DOMException(HIERARCHY_REQUEST_ERR, 'More than one element or text in fragment');
}
if (nodeChildElements.length === 1 && !isElementInsertionPossible(parent, child)) {
throw new DOMException(HIERARCHY_REQUEST_ERR, 'Element in fragment can not be inserted before doctype');
}
}
if (isElementNode(node)) {
if (parentChildNodes.find(isElementNode) || !isElementInsertionPossible(parent, child)) {
throw new DOMException(HIERARCHY_REQUEST_ERR, 'Only one element can be added and only after doctype');
}
}
if (isDocTypeNode(node)) {
if (parentChildNodes.find(isDocTypeNode)) {
throw new DOMException(HIERARCHY_REQUEST_ERR, 'Only one doctype is allowed');
}
let parentElementChild = parentChildNodes.find(isElementNode);
if (child && parentChildNodes.indexOf(parentElementChild) < parentChildNodes.indexOf(child)) {
throw new DOMException(HIERARCHY_REQUEST_ERR, 'Doctype can only be inserted before an element');
}
if (!child && parentElementChild) {
throw new DOMException(HIERARCHY_REQUEST_ERR, 'Doctype can not be appended since element is present');
}
}
}

var cp = node.parentNode;
if (cp) {
cp.removeChild(newChild); //remove and update
cp.removeChild(node); //remove and update
}
if (newChild.nodeType === DOCUMENT_FRAGMENT_NODE) {
var newFirst = newChild.firstChild;
if (node.nodeType === DOCUMENT_FRAGMENT_NODE) {
var newFirst = node.firstChild;
if (newFirst == null) {
return newChild;
return node;
}
var newLast = newChild.lastChild;
var newLast = node.lastChild;
} else {
newFirst = newLast = newChild;
newFirst = newLast = node;
}
var pre = nextChild ? nextChild.previousSibling : parentNode.lastChild;
var pre = child ? child.previousSibling : parent.lastChild;

newFirst.previousSibling = pre;
newLast.nextSibling = nextChild;
newLast.nextSibling = child;

if (pre) {
pre.nextSibling = newFirst;
} else {
parentNode.firstChild = newFirst;
parent.firstChild = newFirst;
}
if (nextChild == null) {
parentNode.lastChild = newLast;
if (child == null) {
parent.lastChild = newLast;
} else {
nextChild.previousSibling = newLast;
child.previousSibling = newLast;
}
do {
newFirst.parentNode = parentNode;
newFirst.parentNode = parent;
} while (newFirst !== newLast && (newFirst = newFirst.nextSibling));
_onUpdateChild(parentNode.ownerDocument || parentNode, parentNode);
//console.log(parentNode.lastChild.nextSibling == null)
if (newChild.nodeType == DOCUMENT_FRAGMENT_NODE) {
newChild.firstChild = newChild.lastChild = null;
_onUpdateChild(parent.ownerDocument || parent, parent);
//console.log(parent.lastChild.nextSibling == null)
if (node.nodeType == DOCUMENT_FRAGMENT_NODE) {
node.firstChild = node.lastChild = null;
}
return newChild;
return node;
}

/**
Expand Down Expand Up @@ -840,11 +994,13 @@ Document.prototype = {
}
return newChild;
}
if (this.documentElement == null && newChild.nodeType == ELEMENT_NODE) {
_insertBefore(this, newChild, refChild);
newChild.ownerDocument = this;
if (this.documentElement === null && newChild.nodeType === ELEMENT_NODE) {
this.documentElement = newChild;
}

return _insertBefore(this, newChild, refChild), (newChild.ownerDocument = this), newChild;
return newChild;
},
removeChild: function (oldChild) {
if (this.documentElement == oldChild) {
Expand Down
76 changes: 70 additions & 6 deletions test/dom/document.test.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
'use strict';

const { getTestParser } = require('../get-test-parser');
const { DOMImplementation } = require('../../lib/dom');
const { DOMImplementation, DOMException } = require('../../lib/dom');
const { NAMESPACE } = require('../../lib/conventions');

const INPUT = (first = '', second = '', third = '', fourth = '') => `
<html >
<body id="body">
<p id="p1" class=" quote first odd ${first} ">Lorem ipsum</p>
<p id="p2" class=" quote second even ${second} ">Lorem ipsum</p>
<p id="p3" class=" quote third odd ${third} ">Lorem ipsum</p>
<p id="p4" class=" quote fourth even ${fourth} ">Lorem ipsum</p>
<body id='body'>
<p id='p1' class=' quote first odd ${first} '>Lorem ipsum</p>
<p id='p2' class=' quote second even ${second} '>Lorem ipsum</p>
<p id='p3' class=' quote third odd ${third} '>Lorem ipsum</p>
<p id='p4' class=' quote fourth even ${fourth} '>Lorem ipsum</p>
</body>
</html>
`;
Expand Down Expand Up @@ -166,4 +166,68 @@ describe('Document.prototype', () => {
expect(attr.nodeName).toBe('name');
});
});
describe('insertBefore', () => {
it('should insert the first element and set `documentElement`', () => {
const doc = new DOMImplementation().createDocument(null, '');
expect(doc.childNodes).toHaveLength(0);
expect(doc.documentElement).toBeNull();
const root = doc.createElement('root');
doc.insertBefore(root);
expect(doc.documentElement).toBe(root);
expect(doc.childNodes).toHaveLength(1);
expect(doc.childNodes.item(0)).toBe(root);
});
it('should prevent inserting a second element', () => {
const doc = new DOMImplementation().createDocument(null, '');
const root = doc.createElement('root');
const second = doc.createElement('second');
doc.insertBefore(root);
expect(() => doc.insertBefore(second)).toThrow(DOMException);
expect(doc.documentElement).toBe(root);
expect(doc.childNodes).toHaveLength(1);
});
it('should prevent inserting an element before a doctype', () => {
const impl = new DOMImplementation();
const doctype = impl.createDocumentType('DT');
const doc = impl.createDocument(null, '', doctype);
expect(doc.childNodes).toHaveLength(1);
const root = doc.createElement('root');
expect(() => doc.insertBefore(root, doctype)).toThrow(DOMException);
expect(doc.documentElement).toBeNull();
expect(doc.childNodes).toHaveLength(1);
expect(root.parentNode).toBeNull();
});
it('should prevent inserting a second doctype', () => {
const impl = new DOMImplementation();
const doctype = impl.createDocumentType('DT');
const doctype2 = impl.createDocumentType('DT2');
const doc = impl.createDocument(null, '', doctype);
expect(doc.childNodes).toHaveLength(1);
expect(() => doc.insertBefore(doctype2)).toThrow(DOMException);
expect(doc.childNodes).toHaveLength(1);
});
it('should prevent inserting a doctype after an element', () => {
const impl = new DOMImplementation();
const doc = impl.createDocument(null, '');
const root = doc.createElement('root');
doc.insertBefore(root);
const doctype = impl.createDocumentType('DT');
expect(doc.childNodes).toHaveLength(1);

expect(() => doc.insertBefore(doctype)).toThrow(DOMException);

expect(doc.childNodes).toHaveLength(1);
});
it('should prevent inserting before an child which is not a child of parent', () => {
const doc = new DOMImplementation().createDocument(null, '');
const root = doc.createElement('root');
const withoutParent = doc.createElement('second');

expect(() => doc.insertBefore(root, withoutParent)).toThrow(DOMException);

expect(doc.documentElement).toBeNull();
expect(doc.childNodes).toHaveLength(0);
expect(root.parentNode).toBeNull();
});
});
});
2 changes: 2 additions & 0 deletions test/dom/dom-implementation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('DOMImplementation', () => {

expect(doc.nodeType).toBe(Node.DOCUMENT_NODE);
expect(doc.implementation).toBe(impl);
expect(doc.ownerDocument).toBe(doc);
expect(doc.doctype).toBe(null);
expect(doc.childNodes).toBeInstanceOf(NodeList);
expect(doc.documentElement).toBe(null);
Expand Down Expand Up @@ -193,6 +194,7 @@ describe('DOMImplementation', () => {
expect(doc.childNodes.length).toBe(0);
expect(doc.doctype).toBeNull();
expect(doc.documentElement).toBeNull();
expect(doc.ownerDocument).toBe(doc);
});
it('should create an HTML document with minimum specified elements when title not provided', () => {
const impl = new DOMImplementation();
Expand Down
4 changes: 2 additions & 2 deletions test/parse/parse-element.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ describe('XML Node Parse', () => {
});

it('sibling closing tag with whitespace', () => {
const actual = new DOMParser().parseFromString(`<book></book ><title>Harry Potter</title>`, 'text/xml').toString();
expect(actual).toBe(`<book/><title>Harry Potter</title>`);
const actual = new DOMParser().parseFromString(`<xml><book></book ><title>Harry Potter</title></xml>`, 'text/xml').toString();
expect(actual).toBe(`<xml><book/><title>Harry Potter</title></xml>`);
});

describe('simple attributes', () => {
Expand Down
Loading

0 comments on commit 52a7083

Please sign in to comment.