Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 1e45057

Browse files
committed
fix(jqLite): prevent possible XSS due to regex-based HTML replacement
1 parent 8e941f4 commit 1e45057

File tree

5 files changed

+114
-5
lines changed

5 files changed

+114
-5
lines changed

src/.eslintrc.json

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"VALIDITY_STATE_PROPERTY": false,
101101
"reloadWithDebugInfo": false,
102102
"stringify": false,
103+
"UNSAFE_restoreLegacyJqLiteXHTMLReplacement": false,
103104

104105
"NODE_TYPE_ELEMENT": false,
105106
"NODE_TYPE_ATTRIBUTE": false,

src/Angular.js

+21
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
hasOwnProperty,
9494
createMap,
9595
stringify,
96+
UNSAFE_restoreLegacyJqLiteXHTMLReplacement,
9697
9798
NODE_TYPE_ELEMENT,
9899
NODE_TYPE_ATTRIBUTE,
@@ -1949,6 +1950,26 @@ function bindJQuery() {
19491950
bindJQueryFired = true;
19501951
}
19511952

1953+
/**
1954+
* @ngdoc function
1955+
* @name angular.UNSAFE_restoreLegacyJqLiteXHTMLReplacement
1956+
* @module ng
1957+
* @kind function
1958+
*
1959+
* @description
1960+
* Restores the pre-1.8 behavior of jqLite that turns XHTML-like strings like
1961+
* `<div /><span />` to `<div></div><span></span>` instead of `<div><span></span></div>`.
1962+
* The new behavior is a security fix so if you use this method, please try to adjust
1963+
* to the change & remove the call as soon as possible.
1964+
1965+
* Note that this only patches jqLite. If you use jQuery 3.5.0 or newer, please read
1966+
* [jQuery 3.5 upgrade guide](https://jquery.com/upgrade-guide/3.5/) for more details
1967+
* about the workarounds.
1968+
*/
1969+
function UNSAFE_restoreLegacyJqLiteXHTMLReplacement() {
1970+
JQLite.legacyXHTMLReplacement = true;
1971+
}
1972+
19521973
/**
19531974
* throw error if the argument is falsy.
19541975
*/

src/AngularPublic.js

+1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ function publishExternalAPI(angular) {
156156
'callbacks': {$$counter: 0},
157157
'getTestability': getTestability,
158158
'reloadWithDebugInfo': reloadWithDebugInfo,
159+
'UNSAFE_restoreLegacyJqLiteXHTMLReplacement': UNSAFE_restoreLegacyJqLiteXHTMLReplacement,
159160
'$$minErr': minErr,
160161
'$$csp': csp,
161162
'$$encodeUriSegment': encodeUriSegment,

src/jqLite.js

+22-5
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@
9090
* - [`val()`](http://api.jquery.com/val/)
9191
* - [`wrap()`](http://api.jquery.com/wrap/)
9292
*
93+
* jqLite also provides a method restoring pre-1.8 insecure treatment of XHTML-like tags
94+
* that makes input like `<div /><span />` turned to `<div></div><span></span>` instead of
95+
* `<div><span></span></div>` like version 1.8 & newer do:
96+
* ```js
97+
* angular.UNSAFE_restoreLegacyJqLiteXHTMLReplacement();
98+
* ```
99+
* Note that this only patches jqLite. If you use jQuery 3.5.0 or newer, please read
100+
* [jQuery 3.5 upgrade guide](https://jquery.com/upgrade-guide/3.5/) for more details
101+
* about the workarounds.
102+
*
93103
* ## jQuery/jqLite Extras
94104
* AngularJS also provides the following additional methods and events to both jQuery and jqLite:
95105
*
@@ -170,19 +180,23 @@ var TAG_NAME_REGEXP = /<([\w:-]+)/;
170180
var XHTML_TAG_REGEXP = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi;
171181

172182
var wrapMap = {
173-
'option': [1, '<select multiple="multiple">', '</select>'],
174-
175183
'thead': [1, '<table>', '</table>'],
176184
'col': [2, '<table><colgroup>', '</colgroup></table>'],
177185
'tr': [2, '<table><tbody>', '</tbody></table>'],
178186
'td': [3, '<table><tbody><tr>', '</tr></tbody></table>'],
179187
'_default': [0, '', '']
180188
};
181189

182-
wrapMap.optgroup = wrapMap.option;
183190
wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
184191
wrapMap.th = wrapMap.td;
185192

193+
// Support: IE <=9 only
194+
// IE <=9 replaces <option> tags with their contents when inserted outside of
195+
// the select element.
196+
if (msie < 10) {
197+
wrapMap.optgroup = wrapMap.option = [1, '<select multiple="multiple">', '</select>'];
198+
}
199+
186200

187201
function jqLiteIsTextNode(html) {
188202
return !HTML_REGEXP.test(html);
@@ -203,7 +217,7 @@ function jqLiteHasData(node) {
203217
}
204218

205219
function jqLiteBuildFragment(html, context) {
206-
var tmp, tag, wrap,
220+
var tmp, tag, wrap, finalHtml,
207221
fragment = context.createDocumentFragment(),
208222
nodes = [], i;
209223

@@ -215,7 +229,10 @@ function jqLiteBuildFragment(html, context) {
215229
tmp = fragment.appendChild(context.createElement('div'));
216230
tag = (TAG_NAME_REGEXP.exec(html) || ['', ''])[1].toLowerCase();
217231
wrap = wrapMap[tag] || wrapMap._default;
218-
tmp.innerHTML = wrap[1] + html.replace(XHTML_TAG_REGEXP, '<$1></$2>') + wrap[2];
232+
finalHtml = JQLite.legacyXHTMLReplacement ?
233+
html.replace(XHTML_TAG_REGEXP, '<$1></$2>') :
234+
html;
235+
tmp.innerHTML = wrap[1] + finalHtml + wrap[2];
219236

220237
// Descend through wrappers to the right content
221238
i = wrap[0];

test/jqLiteSpec.js

+69
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,75 @@ describe('jqLite', function() {
169169
expect(nodes[0].nodeName.toLowerCase()).toBe(name);
170170
});
171171
});
172+
173+
describe('security', function() {
174+
it('shouldn\'t unsanitize sanitized code', function(done) {
175+
// jQuery <3.5.0 fail those tests.
176+
if (isJQuery2x()) {
177+
done();
178+
return;
179+
}
180+
181+
var counter = 0,
182+
assertCount = 13,
183+
container = jqLite('<div></div>');
184+
185+
function donePartial() {
186+
counter++;
187+
if (counter === assertCount) {
188+
container.remove();
189+
delete window.xss;
190+
done();
191+
}
192+
}
193+
194+
jqLite(document.body).append(container);
195+
window.xss = jasmine.createSpy('xss');
196+
197+
// Thanks to Masato Kinugawa from Cure53 for providing the following test cases.
198+
// Note: below test cases need to invoke the xss function with consecutive
199+
// decimal parameters for the assertions to be correct.
200+
forEach([
201+
'<img alt="<x" title="/><img src=url404 onerror=xss(0)>">',
202+
'<img alt="\n<x" title="/>\n<img src=url404 onerror=xss(1)>">',
203+
'<style><style/><img src=url404 onerror=xss(2)>',
204+
'<xmp><xmp/><img src=url404 onerror=xss(3)>',
205+
'<title><title /><img src=url404 onerror=xss(4)>',
206+
'<iframe><iframe/><img src=url404 onerror=xss(5)>',
207+
'<noframes><noframes/><img src=url404 onerror=xss(6)>',
208+
'<noscript><noscript/><img src=url404 onerror=xss(7)>',
209+
'<foo" alt="" title="/><img src=url404 onerror=xss(8)>">',
210+
'<img alt="<x" title="" src="/><img src=url404 onerror=xss(9)>">',
211+
'<noscript/><img src=url404 onerror=xss(10)>',
212+
'<noembed><noembed/><img src=url404 onerror=xss(11)>',
213+
214+
'<option><style></option></select><img src=url404 onerror=xss(12)></style>'
215+
], function(htmlString, index) {
216+
var element = jqLite('<div></div>');
217+
218+
container.append(element);
219+
element.append(jqLite(htmlString));
220+
221+
window.setTimeout(function() {
222+
expect(window.xss).not.toHaveBeenCalledWith(index);
223+
donePartial();
224+
}, 1000);
225+
});
226+
});
227+
228+
it('should allow to restore legacy insecure behavior', function() {
229+
// jQuery doesn't have this API.
230+
if (!_jqLiteMode) return;
231+
232+
// eslint-disable-next-line new-cap
233+
angular.UNSAFE_restoreLegacyJqLiteXHTMLReplacement();
234+
235+
var elem = jqLite('<div/><span/>');
236+
expect(elem.length).toBe(2);
237+
expect(elem[0].nodeName.toLowerCase()).toBe('div');
238+
expect(elem[1].nodeName.toLowerCase()).toBe('span');
239+
});
240+
});
172241
});
173242

174243
describe('_data', function() {

0 commit comments

Comments
 (0)