Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions packages/blaze/dombackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,136 @@ const $jq = (typeof jQuery !== 'undefined' ? jQuery :
if (! $jq)
throw new Error("jQuery not found");

DOMBackend._$jq = $jq;
import sanitizeHtml from 'sanitize-html';

DOMBackend._$jq = $jq;

DOMBackend.getContext = function() {
if (DOMBackend._context) {
return DOMBackend._context;
}
if ( DOMBackend._$jq.support.createHTMLDocument ) {
DOMBackend._context = document.implementation.createHTMLDocument( "" );

// Check if createHTMLDocument is supported directly
if (document.implementation && document.implementation.createHTMLDocument) {
DOMBackend._context = document.implementation.createHTMLDocument("");
Comment on lines +19 to +21
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions checking for createHTMLDocument support directly, but the original code checked via jQuery.support.createHTMLDocument which may have included additional browser-specific checks or polyfills. The direct document.implementation check might not account for all edge cases that jQuery handled. Consider verifying that this simplified check works correctly across all target browsers, especially older ones.

Copilot uses AI. Check for mistakes.

// Set the base href for the created document
// so any parsed elements with URLs
// are based on the document's URL (gh-2965)
const base = DOMBackend._context.createElement( "base" );
const base = DOMBackend._context.createElement("base");
base.href = document.location.href;
DOMBackend._context.head.appendChild( base );
DOMBackend._context.head.appendChild(base);
} else {
DOMBackend._context = document;
}
return DOMBackend._context;
}
DOMBackend.parseHTML = function (html) {
// Return an array of nodes.
//
// jQuery does fancy stuff like creating an appropriate
// container element and setting innerHTML on it, as well
// as working around various IE quirks.
return $jq.parseHTML(html, DOMBackend.getContext()) || [];

DOMBackend.parseHTML = function(html, context) {
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parseHTML function signature accepts a context parameter but never uses it. The original jQuery.parseHTML used the context parameter to determine the document context for parsing. While the current implementation calls DOMBackend.getContext() in the getContext function, the parseHTML function itself doesn't use the context parameter that's passed to it. This breaks the API contract and could cause issues if any code is passing a custom context.

Copilot uses AI. Check for mistakes.
// Don't trim to preserve whitespace
// Handle all falsy values and non-strings
if (!html || typeof html !== 'string') {
return [];
}

// Special handling for table elements to ensure proper parsing
const tableElementMatch = html.match(/<(t(?:body|head|foot|r|d|h))\b/i);
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern for matching table elements uses a non-capturing group for the tag names, but then tries to capture the matched tag. The pattern should use a capturing group: /<(t(?:body|head|foot|r|d|h))\b/i already has a capturing group, but the internal (?:...) is non-capturing. However, this is actually correct - the outer parentheses capture the full tag name including 't'. This works correctly but could be more explicit in the comment explaining the regex pattern.

Copilot uses AI. Check for mistakes.
let container;

if (tableElementMatch) {
const tagName = tableElementMatch[1].toLowerCase();
// Create appropriate container based on the table element
switch (tagName) {
case 'td':
case 'th':
container = document.createElement('tr');
break;
case 'tr':
container = document.createElement('tbody');
break;
case 'tbody':
case 'thead':
case 'tfoot':
container = document.createElement('table');
break;
default:
container = document.createElement('template');
}
Comment on lines +46 to +64
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table element wrapping logic is incomplete. Elements like 'colgroup' and 'col' should be wrapped in a table element for proper parsing, but they fall through to the default case which uses a template element. This will likely cause parsing issues. The switch statement should include a case for 'col' and 'colgroup' similar to tbody/thead/tfoot.

Copilot uses AI. Check for mistakes.
} else {
container = document.createElement('template');
}

// Sanitize the HTML with sanitize-html
const cleanHtml = sanitizeHtml(html, {
allowedTags: [
// Basic elements
'div', 'span', 'p', 'br', 'hr', 'b', 'i', 'em', 'strong', 'u',
'a', 'img', 'pre', 'code', 'blockquote',
// Lists
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
// Headers
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Table elements
'table', 'thead', 'tbody', 'tfoot',
'tr', 'td', 'th', 'col', 'colgroup',
// Form elements
'input', 'textarea', 'select', 'option', 'label', 'button',
// Other elements
'iframe', 'article', 'section', 'header', 'footer', 'nav',
'aside', 'main', 'figure', 'figcaption', 'audio', 'video',
'source', 'canvas', 'details', 'summary'
],
allowedAttributes: {
'*': [
'class', 'id', 'style', 'title', 'role', 'data-*', 'aria-*',
// Allow event handlers
'onclick', 'onmouseover', 'onmouseout', 'onkeydown', 'onkeyup', 'onkeypress',
'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset'
Comment on lines +91 to +94
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing inline event handlers (onclick, onmouseover, etc.) in the sanitization configuration defeats the purpose of XSS prevention. These event handler attributes can execute arbitrary JavaScript code and are a common XSS attack vector. The XSS prevention tests (lines 976-1019) expect these handlers to be stripped, but the configuration explicitly allows them, which means the tests are actually verifying incorrect behavior.

Suggested change
'class', 'id', 'style', 'title', 'role', 'data-*', 'aria-*',
// Allow event handlers
'onclick', 'onmouseover', 'onmouseout', 'onkeydown', 'onkeyup', 'onkeypress',
'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset'
'class', 'id', 'style', 'title', 'role', 'data-*', 'aria-*'

Copilot uses AI. Check for mistakes.
],
'a': ['href', 'target', 'rel'],
'img': ['src', 'alt', 'width', 'height'],
'iframe': ['src', 'width', 'height', 'frameborder', 'allowfullscreen'],
'input': ['type', 'value', 'placeholder', 'checked', 'disabled', 'readonly', 'required', 'pattern', 'min', 'max', 'step', 'minlength', 'maxlength', 'stuff'],
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attribute name 'stuff' appears to be a placeholder or typo in the input element's allowed attributes list. This should either be removed or replaced with a valid attribute name if it was intended to be something else.

Suggested change
'input': ['type', 'value', 'placeholder', 'checked', 'disabled', 'readonly', 'required', 'pattern', 'min', 'max', 'step', 'minlength', 'maxlength', 'stuff'],
'input': ['type', 'value', 'placeholder', 'checked', 'disabled', 'readonly', 'required', 'pattern', 'min', 'max', 'step', 'minlength', 'maxlength'],

Copilot uses AI. Check for mistakes.
'textarea': ['rows', 'cols', 'wrap', 'placeholder', 'disabled', 'readonly', 'required', 'minlength', 'maxlength'],
'select': ['multiple', 'disabled', 'required', 'size'],
'option': ['value', 'selected', 'disabled'],
'button': ['type', 'disabled'],
'col': ['span', 'width'],
'td': ['colspan', 'rowspan', 'headers'],
'th': ['colspan', 'rowspan', 'headers', 'scope']
},
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel', 'data'],
allowedSchemesByTag: {
'img': ['data']
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing 'javascript:' protocol in iframe src attributes is a critical security vulnerability. The allowedSchemes list doesn't include 'javascript', but the test on line 1001-1011 expects javascript: URLs to be stripped. However, sanitize-html may not strip javascript: URLs from iframes by default unless explicitly configured. The configuration should explicitly exclude javascript: protocol or use disallowedTagsMode to ensure proper sanitization.

Suggested change
'img': ['data']
'img': ['data'],
'iframe': ['http', 'https']

Copilot uses AI. Check for mistakes.
},
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
allowProtocolRelative: true,
parser: {
lowerCaseTags: false, // Preserve tag case for proper testing
decodeEntities: true
},
// Preserve empty attributes
transformTags: {
'*': function(tagName, attribs) {
// Convert null/undefined attributes to empty strings
Object.keys(attribs).forEach(key => {
if (attribs[key] === null || attribs[key] === undefined) {
delete attribs[key];
}
});
return {
tagName,
attribs
};
}
}
});
Comment on lines +69 to +133
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using sanitize-html fundamentally changes the behavior of parseHTML compared to jQuery's implementation. The jQuery.parseHTML function did NOT sanitize HTML - it simply parsed it into DOM nodes. This is a breaking change that alters the API contract. Applications relying on parseHTML to preserve all HTML content (including scripts and event handlers for legitimate use cases like template compilation) will break. Consider implementing parseHTML without sanitization, as the original function was for parsing, not sanitizing.

Copilot uses AI. Check for mistakes.

// Parse the sanitized HTML
container.innerHTML = cleanHtml;

// Return the nodes, handling both template and regular elements
return Array.from(container instanceof HTMLTemplateElement ? container.content.childNodes : container.childNodes);
Comment on lines +63 to +139
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using HTMLTemplateElement is not supported in Internet Explorer 11 and older browsers. The code creates a template element on line 63 and 66, then checks if it's an HTMLTemplateElement on line 139. For browsers that don't support template elements, this will fail. The PR description mentions cross-browser compatibility as a key concern, but this implementation breaks IE11 support that jQuery.parseHTML provided.

Copilot uses AI. Check for mistakes.
};

DOMBackend.Events = {
Expand Down
3 changes: 2 additions & 1 deletion packages/blaze/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ Npm.depends({
'lodash.has': '4.5.2',
'lodash.isfunction': '3.0.9',
'lodash.isempty': '4.4.0',
'lodash.isobject': '3.0.2'
'lodash.isobject': '3.0.2',
'sanitize-html': '2.11.0'
Comment on lines +12 to +13
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sanitize-html library adds a significant dependency (with its own transitive dependencies) to the blaze package. This increases bundle size and adds a server-side dependency for what should be a client-side parsing operation. Consider using a lighter-weight solution or implementing native parsing without sanitization, especially since jQuery.parseHTML didn't sanitize content.

Suggested change
'lodash.isobject': '3.0.2',
'sanitize-html': '2.11.0'
'lodash.isobject': '3.0.2'

Copilot uses AI. Check for mistakes.
});

Package.onUse(function (api) {
Expand Down
232 changes: 232 additions & 0 deletions packages/blaze/render_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,235 @@ if (typeof MutationObserver !== 'undefined') {
}, 0);
});
}

Tinytest.add("blaze - dombackend - parseHTML", function (test) {
// Test basic HTML parsing
const basicHtml = "<div>Hello</div>";
const basicResult = Blaze._DOMBackend.parseHTML(basicHtml);
test.equal(basicResult.length, 1);
test.equal(basicResult[0].nodeName, "DIV");
test.equal(basicResult[0].textContent || basicResult[0].innerText, "Hello"); // innerText for IE

// Test various falsy/empty inputs (from jQuery tests)
test.equal(Blaze._DOMBackend.parseHTML().length, 0, "Without arguments");
test.equal(Blaze._DOMBackend.parseHTML(undefined).length, 0, "Undefined");
test.equal(Blaze._DOMBackend.parseHTML(null).length, 0, "Null");
test.equal(Blaze._DOMBackend.parseHTML(false).length, 0, "Boolean false");
test.equal(Blaze._DOMBackend.parseHTML(0).length, 0, "Zero");
test.equal(Blaze._DOMBackend.parseHTML(true).length, 0, "Boolean true");
test.equal(Blaze._DOMBackend.parseHTML(42).length, 0, "Positive number");
test.equal(Blaze._DOMBackend.parseHTML("").length, 0, "Empty string");

// Test whitespace preservation (from jQuery tests)
const leadingWhitespace = Blaze._DOMBackend.parseHTML("\t<div></div>");
test.equal(leadingWhitespace[0].nodeType, Node.TEXT_NODE, "First node should be text node");
test.equal(leadingWhitespace[0].nodeValue, "\t", "Leading whitespace should be preserved");

const surroundingWhitespace = Blaze._DOMBackend.parseHTML(" <div></div> ");
test.equal(surroundingWhitespace[0].nodeType, Node.TEXT_NODE, "Leading space should be text node");
test.equal(surroundingWhitespace[2].nodeType, Node.TEXT_NODE, "Trailing space should be text node");

// Test anchor href preservation (from jQuery gh-2965)
const anchor = Blaze._DOMBackend.parseHTML("<a href='example.html'></a>")[0];
test.ok(anchor.href.endsWith("example.html"), "href attribute should be preserved");

// Test malformed HTML handling
const malformedTestCases = [
{
html: "<span><span>", // Unclosed tags
expectedLength: 1
},
{
html: "<td><td>", // Multiple table cells
expectedLength: 2
},
{
html: "<div class=''''''><span><<<>>></span", // Invalid attributes and unclosed tags
expectedLength: 1 // Should attempt to fix malformed HTML
},
{
html: "<html><!DOCTYPE html><head></head><body>invalid order</body></html>", // Wrong DOM structure order
expectedLength: 1 // Should still parse despite invalid structure
}
];

malformedTestCases.forEach((testCase, i) => {
const result = Blaze._DOMBackend.parseHTML(testCase.html);
test.equal(result.length, testCase.expectedLength,
`Malformed test ${i}: Expected length ${testCase.expectedLength} but got ${result.length}`);
});

// Test plain text (no HTML)
const textOnly = "Just some text";
const textResult = Blaze._DOMBackend.parseHTML(textOnly);
test.equal(textResult.length, 1);
test.equal(textResult[0].nodeType, Node.TEXT_NODE);
test.equal(textResult[0].textContent || textResult[0].nodeValue, "Just some text");

// Test self-closing tags
const selfClosing = "<div/>Content";
const selfClosingResult = Blaze._DOMBackend.parseHTML(selfClosing);
test.equal(selfClosingResult.length, 1);
test.equal(selfClosingResult[0].nodeName, "DIV");
test.equal(selfClosingResult[0].nodeType, Node.ELEMENT_NODE);
Comment on lines +854 to +858
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The self-closing tag test on line 854 expects parseHTML to handle "<div/>Content" by creating a DIV element with "Content" as text content. However, sanitize-html may parse this differently than jQuery, potentially creating an empty div followed by a text node. The test should verify the actual structure that results from this input, including checking if "Content" is inside or outside the div element.

Copilot uses AI. Check for mistakes.

// Test nested table elements (testing proper wrapping levels)
const nestedTable = "<td>Cell</td>";
const nestedResult = Blaze._DOMBackend.parseHTML(nestedTable);
test.equal(nestedResult.length, 1);
test.equal(nestedResult[0].nodeName, "TD");

// Test table elements (IE has special requirements)
const tableTestCases = {
tr: {
html: "<tr><td>Cell</td></tr>",
expectedTags: ["TR", "TD"]
},
td: {
html: "<td>Cell</td>",
expectedTags: ["TD"]
},
tbody: {
html: "<tbody><tr><td>Cell</td></tr></tbody>",
expectedTags: ["TBODY", "TR", "TD"]
},
thead: {
html: "<thead><tr><th>Header</th></tr></thead>",
expectedTags: ["THEAD", "TR", "TH"]
},
tfoot: {
html: "<tfoot><tr><td>Footer</td></tr></tfoot>",
expectedTags: ["TFOOT", "TR", "TD"]
},
colgroup: {
html: "<colgroup><col span='2'></colgroup>",
expectedTags: ["COLGROUP", "COL"]
}
};

Object.entries(tableTestCases).forEach(([testCaseName, testCase]) => {
const result = Blaze._DOMBackend.parseHTML(testCase.html);
const firstNode = result[0];
test.equal(firstNode.nodeName, testCase.expectedTags[0],
`${testCaseName}: Expected ${testCase.expectedTags[0]} but got ${firstNode.nodeName}`);
});

// Test whitespace handling (IE is sensitive to this)
const whitespaceTestCases = [
{
html: " <div>Padded</div> ",
expectedLength: 3, // Leading space + div + trailing space
expectedTag: "DIV"
},
{
html: "\n<div>Newlines</div>\n",
expectedLength: 3, // Leading newline + div + trailing newline
expectedTag: "DIV"
},
{
html: "\t<div>Tabs</div>\t",
expectedLength: 3, // Leading tab + div + trailing tab
expectedTag: "DIV"
}
];

whitespaceTestCases.forEach((testCase, i) => {
const result = Blaze._DOMBackend.parseHTML(testCase.html);
test.equal(result.length, testCase.expectedLength,
`Whitespace test ${i}: Expected length ${testCase.expectedLength} but got ${result.length}`);
// Check the middle node (the div)
test.equal(result[1].nodeName, testCase.expectedTag,
`Whitespace test ${i}: Expected tag ${testCase.expectedTag} but got ${result[1].nodeName}`);
// Verify surrounding nodes are text nodes
test.equal(result[0].nodeType, Node.TEXT_NODE,
`Whitespace test ${i}: Expected leading text node`);
test.equal(result[2].nodeType, Node.TEXT_NODE,
`Whitespace test ${i}: Expected trailing text node`);
});

// Test empty input
test.equal(Blaze._DOMBackend.parseHTML("").length, 0);
test.equal(Blaze._DOMBackend.parseHTML(null).length, 0);
test.equal(Blaze._DOMBackend.parseHTML(undefined).length, 0);
// This is a unique case since a whitespace-only input is parsed as a single text node.
test.equal(Blaze._DOMBackend.parseHTML(" ").length, 1);

// Test malformed HTML (IE is more strict)
const malformedTestCasesIE = [
{
html: "<div>Hello<span>World</span></div>", // Well-formed control case
expectedLength: 1,
expectedChildren: 1
},
{
html: "<div>Test</div><p>", // Partial second tag
expectedLength: 2
},
{
html: "<div class=>Test</div>", // Invalid attribute
expectedLength: 1
}
];

malformedTestCasesIE.forEach((testCase, i) => {
const result = Blaze._DOMBackend.parseHTML(testCase.html);
test.equal(result.length, testCase.expectedLength,
`Malformed test ${i}: Expected length ${testCase.expectedLength} but got ${result.length}`);
if (testCase.expectedChildren !== undefined) {
const childCount = result[0].getElementsByTagName('span').length;
test.equal(childCount, testCase.expectedChildren,
`Malformed test ${i}: Expected ${testCase.expectedChildren} span elements but got ${childCount}`);
}
});

// Test array-like properties of result (important for IE)
const arrayResult = Blaze._DOMBackend.parseHTML("<div></div><span></span>");
test.equal(typeof arrayResult.length, "number", "Result should have length property");
test.equal(typeof arrayResult[0], "object", "Result should have indexed access");
test.equal(arrayResult[0].nodeName, "DIV", "First element should be accessible by index");
});

Tinytest.add("blaze - security - XSS prevention in HTML parsing", function (test) {
const xssTestCases = [
{
html: "<div><p>Test</p><script>alert('XSS')</script></div>",
description: "Prevents inline script execution",
checks: (result) => {
test.equal(result.length, 1, "Should parse into a single element");
const div = result[0];
test.equal(div.querySelector('script'), null, "Script tag should be removed");
test.equal(div.querySelector('p').textContent, "Test", "Safe content should be preserved");
}
Comment on lines +976 to +986
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XSS prevention test expects script tags to be removed (line 984), but this represents a change in behavior from jQuery.parseHTML which would preserve script tags (though not execute them). If the goal is to replace jQuery.parseHTML with equivalent functionality, these tests are validating incorrect behavior. The tests should either be removed, or the implementation should be clearly documented as intentionally diverging from jQuery's behavior for security reasons.

Copilot uses AI. Check for mistakes.
},
{
html: "<div><p>Test</p><img src='x' onerror='alert(\"XSS\")'></div>",
description: "Prevents event handler injection",
checks: (result) => {
test.equal(result.length, 1, "Should parse into a single element");
const div = result[0];
const img = div.querySelector('img');
test.isNotNull(img, "Image element should be preserved");
test.isFalse(img.hasAttribute('onerror'), "Event handler should be stripped");
Comment on lines +988 to +996
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects that onerror event handlers should be stripped (line 996), but the sanitize-html configuration explicitly allows onclick, onmouseover, and other event handlers (lines 92-94). This test will fail with the current implementation because the configuration permits these handlers. Either fix the sanitization configuration to strip all event handlers, or update the test to match the actual behavior.

Copilot uses AI. Check for mistakes.
test.equal(div.querySelector('p').textContent, "Test", "Safe content should be preserved");
}
},
{
html: "<div><p>Test</p><iframe src='javascript:alert(\"XSS\")'></iframe></div>",
description: "Prevents javascript: URL injection",
checks: (result) => {
test.equal(result.length, 1, "Should parse into a single element");
const div = result[0];
const iframe = div.querySelector('iframe');
test.isNotNull(iframe, "iframe element should be preserved");
const src = iframe.getAttribute('src') || '';
test.isFalse(src.includes('javascript:'), "javascript: protocol should be stripped");
test.equal(div.querySelector('p').textContent, "Test", "Safe content should be preserved");
}
}
];

xssTestCases.forEach((testCase, i) => {
const result = Blaze._DOMBackend.parseHTML(testCase.html);
testCase.checks(result);
});
});