-
Notifications
You must be signed in to change notification settings - Fork 27.4k
fix(jqLite): prevent possible XSS due to regex-based HTML replacement #17028
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -90,6 +90,16 @@ | |
* - [`val()`](http://api.jquery.com/val/) | ||
* - [`wrap()`](http://api.jquery.com/wrap/) | ||
* | ||
* jqLite also provides a method restoring pre-1.8 insecure treatment of XHTML-like tags. | ||
* This legacy behavior turns input like `<div /><span />` to `<div></div><span></span>` | ||
* instead of `<div><span></span></div>` like version 1.8 & newer do. To restore it, invoke: | ||
* ```js | ||
* angular.UNSAFE_restoreLegacyJqLiteXHTMLReplacement(); | ||
* ``` | ||
* Note that this only patches jqLite. If you use jQuery 3.5.0 or newer, please read the | ||
* [jQuery 3.5 upgrade guide](https://jquery.com/upgrade-guide/3.5/) for more details | ||
* about the workarounds. | ||
* | ||
* ## jQuery/jqLite Extras | ||
* AngularJS also provides the following additional methods and events to both jQuery and jqLite: | ||
* | ||
|
@@ -169,20 +179,36 @@ var HTML_REGEXP = /<|&#?\w+;/; | |
var TAG_NAME_REGEXP = /<([\w:-]+)/; | ||
var XHTML_TAG_REGEXP = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi; | ||
|
||
// Table parts need to be wrapped with `<table>` or they're | ||
// stripped to their contents when put in a div. | ||
// XHTML parsers do not magically insert elements in the | ||
// same way that tag soup parsers do, so we cannot shorten | ||
// this by omitting <tbody> or other required elements. | ||
var wrapMap = { | ||
'option': [1, '<select multiple="multiple">', '</select>'], | ||
|
||
'thead': [1, '<table>', '</table>'], | ||
'col': [2, '<table><colgroup>', '</colgroup></table>'], | ||
'tr': [2, '<table><tbody>', '</tbody></table>'], | ||
'td': [3, '<table><tbody><tr>', '</tr></tbody></table>'], | ||
'_default': [0, '', ''] | ||
thead: ['table'], | ||
col: ['colgroup', 'table'], | ||
tr: ['tbody', 'table'], | ||
td: ['tr', 'tbody', 'table'] | ||
}; | ||
|
||
wrapMap.optgroup = wrapMap.option; | ||
wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; | ||
wrapMap.th = wrapMap.td; | ||
|
||
// Support: IE <10 only | ||
// IE 9 requires an option wrapper & it needs to have the whole table structure | ||
// set up in advance; assigning `"<td></td>"` to `tr.innerHTML` doesn't work, etc. | ||
var wrapMapIE9 = { | ||
option: [1, '<select multiple="multiple">', '</select>'], | ||
_default: [0, '', ''] | ||
}; | ||
|
||
for (var key in wrapMap) { | ||
var wrapMapValueClosing = wrapMap[key]; | ||
var wrapMapValue = wrapMapValueClosing.slice().reverse(); | ||
wrapMapIE9[key] = [wrapMapValue.length, '<' + wrapMapValue.join('><') + '>', '</' + wrapMapValueClosing.join('></') + '>']; | ||
} | ||
petebacondarwin marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if you actually need the wrapping at all in modern browsers. At least in Chrome appending the elements directly works, even if, say, In any case, while the current patch fixes the security bug we currently know about, a better way would be to avoid concatenating HTML strings whatsoever. If, for example, you need wrapping, use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
You do. See https://jsbin.com/cibiwet/edit?html,js,console, it doesn't actually append the element, either in Firefox or in Chrome. I'm curious how it worked for you, my test case is pretty basic.
That would work. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Heh, my bad. I would've sworn this worked last week (I was debugging jqLite, so all calls were inside a fragment too), but indeed it does not (only |
||
|
||
wrapMapIE9.optgroup = wrapMapIE9.option; | ||
|
||
function jqLiteIsTextNode(html) { | ||
return !HTML_REGEXP.test(html); | ||
|
@@ -203,7 +229,7 @@ function jqLiteHasData(node) { | |
} | ||
|
||
function jqLiteBuildFragment(html, context) { | ||
var tmp, tag, wrap, | ||
var tmp, tag, wrap, finalHtml, | ||
fragment = context.createDocumentFragment(), | ||
nodes = [], i; | ||
|
||
|
@@ -214,13 +240,30 @@ function jqLiteBuildFragment(html, context) { | |
// Convert html into DOM nodes | ||
tmp = fragment.appendChild(context.createElement('div')); | ||
tag = (TAG_NAME_REGEXP.exec(html) || ['', ''])[1].toLowerCase(); | ||
wrap = wrapMap[tag] || wrapMap._default; | ||
tmp.innerHTML = wrap[1] + html.replace(XHTML_TAG_REGEXP, '<$1></$2>') + wrap[2]; | ||
finalHtml = JQLite.legacyXHTMLReplacement ? | ||
html.replace(XHTML_TAG_REGEXP, '<$1></$2>') : | ||
html; | ||
|
||
if (msie < 10) { | ||
wrap = wrapMapIE9[tag] || wrapMapIE9._default; | ||
tmp.innerHTML = wrap[1] + finalHtml + wrap[2]; | ||
|
||
// Descend through wrappers to the right content | ||
i = wrap[0]; | ||
while (i--) { | ||
tmp = tmp.firstChild; | ||
} | ||
} else { | ||
wrap = wrapMap[tag] || []; | ||
|
||
// Descend through wrappers to the right content | ||
i = wrap[0]; | ||
while (i--) { | ||
tmp = tmp.lastChild; | ||
// Create wrappers & descend into them | ||
i = wrap.length; | ||
while (--i > -1) { | ||
tmp.appendChild(window.document.createElement(wrap[i])); | ||
tmp = tmp.firstChild; | ||
} | ||
|
||
tmp.innerHTML = finalHtml; | ||
} | ||
|
||
nodes = concat(nodes, tmp.childNodes); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -147,6 +147,13 @@ describe('jqLite', function() { | |
expect(nodes[0].nodeName.toLowerCase()).toBe('option'); | ||
}); | ||
|
||
it('should allow construction of multiple <option> elements', function() { | ||
var nodes = jqLite('<option></option><option></option>'); | ||
expect(nodes.length).toBe(2); | ||
expect(nodes[0].nodeName.toLowerCase()).toBe('option'); | ||
expect(nodes[1].nodeName.toLowerCase()).toBe('option'); | ||
}); | ||
|
||
|
||
// Special tests for the construction of elements which are restricted (in the HTML5 spec) to | ||
// being children of specific nodes. | ||
|
@@ -169,6 +176,95 @@ describe('jqLite', function() { | |
expect(nodes[0].nodeName.toLowerCase()).toBe(name); | ||
}); | ||
}); | ||
|
||
describe('security', function() { | ||
it('shouldn\'t crash at attempts to close the table wrapper', function() { | ||
// jQuery doesn't pass this test yet. | ||
if (!_jqLiteMode) return; | ||
|
||
// Support: IE <10 | ||
// In IE 9 we still need to use the old-style innerHTML assignment | ||
// as that's the only one that works. | ||
if (msie < 10) return; | ||
|
||
expect(function() { | ||
// This test case attempts to close the tags which wrap input | ||
// based on matching done in wrapMap, escaping the wrapper & thus | ||
// triggering an error when descending. | ||
var el = jqLite('<td></td></tr></tbody></table><td></td>'); | ||
expect(el.length).toBe(2); | ||
expect(el[0].nodeName.toLowerCase()).toBe('td'); | ||
expect(el[1].nodeName.toLowerCase()).toBe('td'); | ||
}).not.toThrow(); | ||
}); | ||
|
||
it('shouldn\'t unsanitize sanitized code', function(done) { | ||
// jQuery <3.5.0 fail those tests. | ||
if (isJQuery2x()) { | ||
done(); | ||
return; | ||
} | ||
|
||
var counter = 0, | ||
assertCount = 13, | ||
container = jqLite('<div></div>'); | ||
|
||
function donePartial() { | ||
counter++; | ||
if (counter === assertCount) { | ||
container.remove(); | ||
delete window.xss; | ||
done(); | ||
} | ||
} | ||
|
||
jqLite(document.body).append(container); | ||
window.xss = jasmine.createSpy('xss'); | ||
|
||
// Thanks to Masato Kinugawa from Cure53 for providing the following test cases. | ||
// Note: below test cases need to invoke the xss function with consecutive | ||
// decimal parameters for the assertions to be correct. | ||
forEach([ | ||
'<img alt="<x" title="/><img src=url404 onerror=xss(0)>">', | ||
'<img alt="\n<x" title="/>\n<img src=url404 onerror=xss(1)>">', | ||
'<style><style/><img src=url404 onerror=xss(2)>', | ||
'<xmp><xmp/><img src=url404 onerror=xss(3)>', | ||
'<title><title /><img src=url404 onerror=xss(4)>', | ||
'<iframe><iframe/><img src=url404 onerror=xss(5)>', | ||
'<noframes><noframes/><img src=url404 onerror=xss(6)>', | ||
'<noscript><noscript/><img src=url404 onerror=xss(7)>', | ||
'<foo" alt="" title="/><img src=url404 onerror=xss(8)>">', | ||
'<img alt="<x" title="" src="/><img src=url404 onerror=xss(9)>">', | ||
'<noscript/><img src=url404 onerror=xss(10)>', | ||
'<noembed><noembed/><img src=url404 onerror=xss(11)>', | ||
|
||
'<option><style></option></select><img src=url404 onerror=xss(12)></style>' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that the following test cases were useful for jQuery (which used a slightly different regex) but not for jqLite's XHTML_TAG_REGEXP (i.e. they would pass without the changes in this PR, so they don't add much value):
(That is because jQuery's tegex would match There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We’re testing Angular with jQuery as well here. I’d leave them all; the purpose is not to match only what was broken but to prevent future regressions here. |
||
], function(htmlString, index) { | ||
var element = jqLite('<div></div>'); | ||
|
||
container.append(element); | ||
element.append(jqLite(htmlString)); | ||
|
||
window.setTimeout(function() { | ||
expect(window.xss).not.toHaveBeenCalledWith(index); | ||
donePartial(); | ||
}, 1000); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to get a false-positive (i.e. an anti-flake)? I guess that the Could we avoid relying upon the timeout being long enough. Perhaps create an additional "real" error that will call a different There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feel free to experiment. I've tried a few things when I prepared a similar patch for jQuery & I wasn't able to achieve something that would not have the delay and that wouldn't suffer from race conditions. Maybe there's something but it might require extensive testing - we definitely don't want to miss the error just because it fired too late. |
||
}); | ||
}); | ||
|
||
it('should allow to restore legacy insecure behavior', function() { | ||
// jQuery doesn't have this API. | ||
if (!_jqLiteMode) return; | ||
|
||
// eslint-disable-next-line new-cap | ||
angular.UNSAFE_restoreLegacyJqLiteXHTMLReplacement(); | ||
|
||
var elem = jqLite('<div/><span/>'); | ||
expect(elem.length).toBe(2); | ||
mgol marked this conversation as resolved.
Show resolved
Hide resolved
|
||
expect(elem[0].nodeName.toLowerCase()).toBe('div'); | ||
expect(elem[1].nodeName.toLowerCase()).toBe('span'); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('_data', function() { | ||
|
Uh oh!
There was an error while loading. Please reload this page.