diff --git a/src/.eslintrc.json b/src/.eslintrc.json index 64e8b866e6c8..e053a4dfb972 100644 --- a/src/.eslintrc.json +++ b/src/.eslintrc.json @@ -161,6 +161,7 @@ "urlResolve": false, "urlIsSameOrigin": false, "urlIsSameOriginAsBaseUrl": false, + "urlIsAllowedOriginFactory": false, /* ng/controller.js */ "identifierForController": false, diff --git a/src/Angular.js b/src/Angular.js index f5ab043dc8a3..7eda3a028b6b 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -1369,7 +1369,7 @@ function convertTimezoneToLocal(date, timezone, reverse) { */ function startingTag(element) { element = jqLite(element).clone().empty(); - var elemHtml = jqLite('
').append(element).html(); + var elemHtml = jqLite('
').append(element).html(); try { return element[0].nodeType === NODE_TYPE_TEXT ? lowercase(elemHtml) : elemHtml. @@ -1924,25 +1924,25 @@ function bindJQuery() { injector: JQLitePrototype.injector, inheritedData: JQLitePrototype.inheritedData }); - - // All nodes removed from the DOM via various jQuery APIs like .remove() - // are passed through jQuery.cleanData. Monkey-patch this method to fire - // the $destroy event on all removed nodes. - originalCleanData = jQuery.cleanData; - jQuery.cleanData = function(elems) { - var events; - for (var i = 0, elem; (elem = elems[i]) != null; i++) { - events = jQuery._data(elem, 'events'); - if (events && events.$destroy) { - jQuery(elem).triggerHandler('$destroy'); - } - } - originalCleanData(elems); - }; } else { jqLite = JQLite; } + // All nodes removed from the DOM via various jqLite/jQuery APIs like .remove() + // are passed through jqLite/jQuery.cleanData. Monkey-patch this method to fire + // the $destroy event on all removed nodes. + originalCleanData = jqLite.cleanData; + jqLite.cleanData = function(elems) { + var events; + for (var i = 0, elem; (elem = elems[i]) != null; i++) { + events = jqLite._data(elem).events; + if (events && events.$destroy) { + jqLite(elem).triggerHandler('$destroy'); + } + } + originalCleanData(elems); + }; + angular.element = jqLite; // Prevent double-proxying. diff --git a/src/jqLite.js b/src/jqLite.js index f7913ac4312f..9884730d5e13 100644 --- a/src/jqLite.js +++ b/src/jqLite.js @@ -54,7 +54,8 @@ * * - [`addClass()`](http://api.jquery.com/addClass/) - Does not support a function as first argument * - [`after()`](http://api.jquery.com/after/) - * - [`append()`](http://api.jquery.com/append/) + * - [`append()`](http://api.jquery.com/append/) - Contrary to jQuery, this doesn't clone elements + * so will not work correctly when invoked on a jqLite object containing more than one DOM node * - [`attr()`](http://api.jquery.com/attr/) - Does not support functions as parameters * - [`bind()`](http://api.jquery.com/bind/) (_deprecated_, use [`on()`](http://api.jquery.com/on/)) - Does not support namespaces, selectors or eventData * - [`children()`](http://api.jquery.com/children/) - Does not support selectors @@ -310,6 +311,28 @@ function jqLiteDealoc(element, onlyDescendants) { } } +function isEmptyObject(obj) { + var name; + + for (name in obj) { + return false; + } + return true; +} + +function removeIfEmptyData(element) { + var expandoId = element.ng339; + var expandoStore = expandoId && jqCache[expandoId]; + + var events = expandoStore && expandoStore.events; + var data = expandoStore && expandoStore.data; + + if ((!data || isEmptyObject(data)) && (!events || isEmptyObject(events))) { + delete jqCache[expandoId]; + element.ng339 = undefined; // don't delete DOM expandos. IE and Chrome don't like it + } +} + function jqLiteOff(element, type, fn, unsupported) { if (isDefined(unsupported)) throw jqLiteMinErr('offargs', 'jqLite#off() does not support the `selector` argument'); @@ -346,6 +369,8 @@ function jqLiteOff(element, type, fn, unsupported) { } }); } + + removeIfEmptyData(element); } function jqLiteRemoveData(element, name) { @@ -355,17 +380,11 @@ function jqLiteRemoveData(element, name) { if (expandoStore) { if (name) { delete expandoStore.data[name]; - return; + } else { + expandoStore.data = {}; } - if (expandoStore.handle) { - if (expandoStore.events.$destroy) { - expandoStore.handle({}, '$destroy'); - } - jqLiteOff(element); - } - delete jqCache[expandoId]; - element.ng339 = undefined; // don't delete DOM expandos. IE and Chrome don't like it + removeIfEmptyData(element); } } @@ -615,6 +634,7 @@ forEach({ cleanData: function jqLiteCleanData(nodes) { for (var i = 0, ii = nodes.length; i < ii; i++) { jqLiteRemoveData(nodes[i]); + jqLiteOff(nodes[i]); } } }, function(fn, name) { diff --git a/src/ng/compile.js b/src/ng/compile.js index 36b64fe4e41b..63c1d10c142e 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -1941,7 +1941,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // for call to the link function. // Note: This will already clone the nodes... $linkNode = jqLite( - wrapTemplate(namespace, jqLite('
').append($compileNodes).html()) + wrapTemplate(namespace, jqLite('
').append($compileNodes).html()) ); } else if (cloneConnectFn) { // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart diff --git a/src/ng/http.js b/src/ng/http.js index 0e44977cfc8b..c8f69ee58ab1 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -34,7 +34,7 @@ function $HttpParamSerializerProvider() { * * `{'foo': {'bar':'baz'}}` results in `foo=%7B%22bar%22%3A%22baz%22%7D` (stringified and encoded representation of an object) * * Note that serializer will sort the request parameters alphabetically. - * */ + */ this.$get = function() { return function ngParamSerializer(params) { @@ -101,7 +101,7 @@ function $HttpParamSerializerJQLikeProvider() { * }); * ``` * - * */ + */ this.$get = function() { return function jQueryLikeParamSerializer(params) { if (!params) return ''; @@ -261,7 +261,7 @@ function isSuccess(status) { * * @description * Use `$httpProvider` to change the default behavior of the {@link ng.$http $http} service. - * */ + */ function $HttpProvider() { /** * @ngdoc property @@ -315,7 +315,7 @@ function $HttpProvider() { * - **`defaults.xsrfHeaderName`** - {string} - Name of HTTP header to populate with the * XSRF token. Defaults value is `'X-XSRF-TOKEN'`. * - **/ + */ var defaults = this.defaults = { // transform incoming response data transformResponse: [defaultHttpResponseTransform], @@ -362,7 +362,7 @@ function $HttpProvider() { * * @returns {boolean|Object} If a value is specified, returns the $httpProvider for chaining. * otherwise, returns the current configured value. - **/ + */ this.useApplyAsync = function(value) { if (isDefined(value)) { useApplyAsync = !!value; @@ -383,9 +383,51 @@ function $HttpProvider() { * array, on request, but reverse order, on response. * * {@link ng.$http#interceptors Interceptors detailed info} - **/ + */ var interceptorFactories = this.interceptors = []; + /** + * @ngdoc property + * @name $httpProvider#xsrfWhitelistedOrigins + * @description + * + * Array containing URLs whose origins are trusted to receive the XSRF token. See the + * {@link ng.$http#security-considerations Security Considerations} sections for more details on + * XSRF. + * + * **Note:** An "origin" consists of the [URI scheme](https://en.wikipedia.org/wiki/URI_scheme), + * the [hostname](https://en.wikipedia.org/wiki/Hostname) and the + * [port number](https://en.wikipedia.org/wiki/Port_(computer_networking). For `http:` and + * `https:`, the port number can be omitted if using th default ports (80 and 443 respectively). + * Examples: `http://example.com`, `https://api.example.com:9876` + * + *
+ * It is not possible to whitelist specific URLs/paths. The `path`, `query` and `fragment` parts + * of a URL will be ignored. For example, `https://foo.com/path/bar?query=baz#fragment` will be + * treated as `https://foo.com`, meaning that **all** requests to URLs starting with + * `https://foo.com/` will include the XSRF token. + *
+ * + * @example + * + * ```js + * // App served from `https://example.com/`. + * angular. + * module('xsrfWhitelistedOriginsExample', []). + * config(['$httpProvider', function($httpProvider) { + * $httpProvider.xsrfWhitelistedOrigins.push('https://api.example.com'); + * }]). + * run(['$http', function($http) { + * // The XSRF token will be sent. + * $http.get('https://api.example.com/preferences').then(...); + * + * // The XSRF token will NOT be sent. + * $http.get('https://stats.example.com/activity').then(...); + * }]); + * ``` + */ + var xsrfWhitelistedOrigins = this.xsrfWhitelistedOrigins = []; + this.$get = ['$browser', '$httpBackend', '$$cookieReader', '$cacheFactory', '$rootScope', '$q', '$injector', '$sce', function($browser, $httpBackend, $$cookieReader, $cacheFactory, $rootScope, $q, $injector, $sce) { @@ -409,6 +451,11 @@ function $HttpProvider() { ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory)); }); + /** + * A function to check request URLs against a list of allowed origins. + */ + var urlIsAllowedOrigin = urlIsAllowedOriginFactory(xsrfWhitelistedOrigins); + /** * @ngdoc service * @kind function @@ -765,25 +812,42 @@ function $HttpProvider() { * which the attacker can trick an authenticated user into unknowingly executing actions on your * website. AngularJS provides a mechanism to counter XSRF. When performing XHR requests, the * $http service reads a token from a cookie (by default, `XSRF-TOKEN`) and sets it as an HTTP - * header (`X-XSRF-TOKEN`). Since only JavaScript that runs on your domain could read the - * cookie, your server can be assured that the XHR came from JavaScript running on your domain. - * The header will not be set for cross-domain requests. + * header (by default `X-XSRF-TOKEN`). Since only JavaScript that runs on your domain could read + * the cookie, your server can be assured that the XHR came from JavaScript running on your + * domain. * * To take advantage of this, your server needs to set a token in a JavaScript readable session * cookie called `XSRF-TOKEN` on the first HTTP GET request. On subsequent XHR requests the - * server can verify that the cookie matches `X-XSRF-TOKEN` HTTP header, and therefore be sure - * that only JavaScript running on your domain could have sent the request. The token must be - * unique for each user and must be verifiable by the server (to prevent the JavaScript from + * server can verify that the cookie matches the `X-XSRF-TOKEN` HTTP header, and therefore be + * sure that only JavaScript running on your domain could have sent the request. The token must + * be unique for each user and must be verifiable by the server (to prevent the JavaScript from * making up its own tokens). We recommend that the token is a digest of your site's * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) * for added security. * - * The name of the headers can be specified using the xsrfHeaderName and xsrfCookieName - * properties of either $httpProvider.defaults at config-time, $http.defaults at run-time, - * or the per-request config object. + * The header will — by default — **not** be set for cross-domain requests. This + * prevents unauthorized servers (e.g. malicious or compromised 3rd-party APIs) from gaining + * access to your users' XSRF tokens and exposing them to Cross Site Request Forgery. If you + * want to, you can whitelist additional origins to also receive the XSRF token, by adding them + * to {@link ng.$httpProvider#xsrfWhitelistedOrigins xsrfWhitelistedOrigins}. This might be + * useful, for example, if your application, served from `example.com`, needs to access your API + * at `api.example.com`. + * See {@link ng.$httpProvider#xsrfWhitelistedOrigins $httpProvider.xsrfWhitelistedOrigins} for + * more details. + * + *
+ * **Warning**
+ * Only whitelist origins that you have control over and make sure you understand the + * implications of doing so. + *
+ * + * The name of the cookie and the header can be specified using the `xsrfCookieName` and + * `xsrfHeaderName` properties of either `$httpProvider.defaults` at config-time, + * `$http.defaults` at run-time, or the per-request config object. * * In order to prevent collisions in environments where multiple AngularJS apps share the - * same domain or subdomain, we recommend that each application uses unique cookie name. + * same domain or subdomain, we recommend that each application uses a unique cookie name. + * * * @param {object} config Object describing the request to be made and how it should be * processed. The object has following properties: @@ -1343,7 +1407,7 @@ function $HttpProvider() { // if we won't have the response in cache, set the xsrf headers and // send the request to the backend if (isUndefined(cachedResp)) { - var xsrfValue = urlIsSameOrigin(config.url) + var xsrfValue = urlIsAllowedOrigin(config.url) ? $$cookieReader()[config.xsrfCookieName || defaults.xsrfCookieName] : undefined; if (xsrfValue) { diff --git a/src/ng/urlUtils.js b/src/ng/urlUtils.js index 2af0c5f6b753..149e14c707b3 100644 --- a/src/ng/urlUtils.js +++ b/src/ng/urlUtils.js @@ -40,7 +40,8 @@ var baseUrlParsingNode; * http://james.padolsey.com/javascript/parsing-urls-with-the-dom/ * * @kind function - * @param {string} url The URL to be parsed. + * @param {string|object} url The URL to be parsed. If `url` is not a string, it will be returned + * unchanged. * @description Normalizes and parses a URL. * @returns {object} Returns the normalized URL as a dictionary. * @@ -57,6 +58,8 @@ var baseUrlParsingNode; * */ function urlResolve(url) { + if (!isString(url)) return url; + var href = url; // Support: IE 9-11 only @@ -84,7 +87,8 @@ function urlResolve(url) { } /** - * Parse a request URL and determine whether this is a same-origin request as the application document. + * Parse a request URL and determine whether this is a same-origin request as the application + * document. * * @param {string|object} requestUrl The url of the request as a string that will be resolved * or a parsed URL object. @@ -109,17 +113,46 @@ function urlIsSameOriginAsBaseUrl(requestUrl) { } /** - * Determines if two URLs share the same origin. + * Create a function that can check a URL's origin against a list of allowed/whitelisted origins. + * The current location's origin is implicitly trusted. * - * @param {string|object} url1 First URL to compare as a string or a normalized URL in the form of - * a dictionary object returned by `urlResolve()`. - * @param {string|object} url2 Second URL to compare as a string or a normalized URL in the form of + * @param {string[]} whitelistedOriginUrls - A list of URLs (strings), whose origins are trusted. + * + * @returns {Function} - A function that receives a URL (string or parsed URL object) and returns + * whether it is of an allowed origin. + */ +function urlIsAllowedOriginFactory(whitelistedOriginUrls) { + var parsedAllowedOriginUrls = [originUrl].concat(whitelistedOriginUrls.map(urlResolve)); + + /** + * Check whether the specified URL (string or parsed URL object) has an origin that is allowed + * based on a list of whitelisted-origin URLs. The current location's origin is implicitly + * trusted. + * + * @param {string|Object} requestUrl - The URL to be checked (provided as a string that will be + * resolved or a parsed URL object). + * + * @returns {boolean} - Whether the specified URL is of an allowed origin. + */ + return function urlIsAllowedOrigin(requestUrl) { + var parsedUrl = urlResolve(requestUrl); + return parsedAllowedOriginUrls.some(urlsAreSameOrigin.bind(null, parsedUrl)); + }; +} + +/** + * Determine if two URLs share the same origin. + * + * @param {string|Object} url1 - First URL to compare as a string or a normalized URL in the form of * a dictionary object returned by `urlResolve()`. - * @return {boolean} True if both URLs have the same origin, and false otherwise. + * @param {string|object} url2 - Second URL to compare as a string or a normalized URL in the form + * of a dictionary object returned by `urlResolve()`. + * + * @returns {boolean} - True if both URLs have the same origin, and false otherwise. */ function urlsAreSameOrigin(url1, url2) { - url1 = (isString(url1)) ? urlResolve(url1) : url1; - url2 = (isString(url2)) ? urlResolve(url2) : url2; + url1 = urlResolve(url1); + url2 = urlResolve(url2); return (url1.protocol === url2.protocol && url1.host === url2.host); @@ -127,19 +160,19 @@ function urlsAreSameOrigin(url1, url2) { /** * Returns the current document base URL. - * @return {string} + * @returns {string} */ function getBaseUrl() { if (window.document.baseURI) { return window.document.baseURI; } - // document.baseURI is available everywhere except IE + // `document.baseURI` is available everywhere except IE if (!baseUrlParsingNode) { baseUrlParsingNode = window.document.createElement('a'); baseUrlParsingNode.href = '.'; - // Work-around for IE bug described in Implementation Notes. The fix in urlResolve() is not + // Work-around for IE bug described in Implementation Notes. The fix in `urlResolve()` is not // suitable here because we need to track changes to the base URL. baseUrlParsingNode = baseUrlParsingNode.cloneNode(false); } diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 6401cb26f590..28052a8b8af5 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -152,7 +152,8 @@ /* urlUtils.js */ "urlResolve": false, "urlIsSameOrigin": false, - "urlIsSameOriginAsBaseUrl": true, + "urlIsSameOriginAsBaseUrl": false, + "urlIsAllowedOriginFactory": false, /* karma */ "dump": false, diff --git a/test/jqLiteSpec.js b/test/jqLiteSpec.js index 8ca865d31112..5e8bbbfcdc46 100644 --- a/test/jqLiteSpec.js +++ b/test/jqLiteSpec.js @@ -420,6 +420,87 @@ describe('jqLite', function() { selected.removeData('prop2'); }); + it('should not remove event handlers on removeData()', function() { + var log = ''; + var elm = jqLite(a); + elm.on('click', function() { + log += 'click;'; + }); + + elm.removeData(); + browserTrigger(a, 'click'); + expect(log).toBe('click;'); + }); + + it('should allow to set data after removeData() with event handlers present', function() { + var elm = jqLite(a); + elm.on('click', function() {}); + elm.data('key1', 'value1'); + elm.removeData(); + elm.data('key2', 'value2'); + expect(elm.data('key1')).not.toBeDefined(); + expect(elm.data('key2')).toBe('value2'); + }); + + it('should allow to set data after removeData() without event handlers present', function() { + var elm = jqLite(a); + elm.data('key1', 'value1'); + elm.removeData(); + elm.data('key2', 'value2'); + expect(elm.data('key1')).not.toBeDefined(); + expect(elm.data('key2')).toBe('value2'); + }); + + + it('should remove user data on cleanData()', function() { + var selected = jqLite([a, b, c]); + + selected.data('prop', 'value'); + jqLite(b).data('prop', 'new value'); + + jqLite.cleanData(selected); + + expect(jqLite(a).data('prop')).toBeUndefined(); + expect(jqLite(b).data('prop')).toBeUndefined(); + expect(jqLite(c).data('prop')).toBeUndefined(); + }); + + it('should remove event handlers on cleanData()', function() { + var selected = jqLite([a, b, c]); + + var log = ''; + var elm = jqLite(b); + elm.on('click', function() { + log += 'click;'; + }); + jqLite.cleanData(selected); + + browserTrigger(b, 'click'); + expect(log).toBe(''); + }); + + it('should remove user data & event handlers on cleanData()', function() { + var selected = jqLite([a, b, c]); + + var log = ''; + var elm = jqLite(b); + elm.on('click', function() { + log += 'click;'; + }); + + selected.data('prop', 'value'); + jqLite(a).data('prop', 'new value'); + + jqLite.cleanData(selected); + + browserTrigger(b, 'click'); + expect(log).toBe(''); + + expect(jqLite(a).data('prop')).toBeUndefined(); + expect(jqLite(b).data('prop')).toBeUndefined(); + expect(jqLite(c).data('prop')).toBeUndefined(); + }); + it('should add and remove data on SVGs', function() { var svg = jqLite(''); diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index 73a5fccbe44c..f1cf0e896fb5 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -11,22 +11,17 @@ describe('$http', function() { return Object.keys(params).join('_'); }; - beforeEach(function() { + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + callback = jasmine.createSpy('done'); mockedCookies = {}; - module({ - $$cookieReader: function() { - return mockedCookies; - } - }); - }); + })); beforeEach(module({ + $$cookieReader: function() { return mockedCookies; }, customParamSerializer: customParamSerializer })); - beforeEach(module(function($exceptionHandlerProvider) { - $exceptionHandlerProvider.mode('log'); - })); afterEach(inject(function($exceptionHandler, $httpBackend, $rootScope) { forEach($exceptionHandler.errors, function(e) { @@ -37,7 +32,6 @@ describe('$http', function() { throw 'Unhandled exceptions trapped in $exceptionHandler!'; } - $rootScope.$digest(); $httpBackend.verifyNoOutstandingExpectation(); })); @@ -723,18 +717,6 @@ describe('$http', function() { $httpBackend.flush(); }); - it('should not set XSRF cookie for cross-domain requests', inject(function($browser) { - mockedCookies['XSRF-TOKEN'] = 'secret'; - $browser.url('http://host.com/base'); - $httpBackend.expect('GET', 'http://www.test.com/url', undefined, function(headers) { - return isUndefined(headers['X-XSRF-TOKEN']); - }).respond(''); - - $http({url: 'http://www.test.com/url', method: 'GET', headers: {}}); - $httpBackend.flush(); - })); - - it('should not send Content-Type header if request data/body is undefined', function() { $httpBackend.expect('POST', '/url', undefined, function(headers) { return !headers.hasOwnProperty('Content-Type'); @@ -766,32 +748,6 @@ describe('$http', function() { $httpBackend.flush(); }); - it('should set the XSRF cookie into a XSRF header', inject(function() { - function checkXSRF(secret, header) { - return function(headers) { - return headers[header || 'X-XSRF-TOKEN'] === secret; - }; - } - - mockedCookies['XSRF-TOKEN'] = 'secret'; - mockedCookies['aCookie'] = 'secret2'; - $httpBackend.expect('GET', '/url', undefined, checkXSRF('secret')).respond(''); - $httpBackend.expect('POST', '/url', undefined, checkXSRF('secret')).respond(''); - $httpBackend.expect('PUT', '/url', undefined, checkXSRF('secret')).respond(''); - $httpBackend.expect('DELETE', '/url', undefined, checkXSRF('secret')).respond(''); - $httpBackend.expect('GET', '/url', undefined, checkXSRF('secret', 'aHeader')).respond(''); - $httpBackend.expect('GET', '/url', undefined, checkXSRF('secret2')).respond(''); - - $http({url: '/url', method: 'GET'}); - $http({url: '/url', method: 'POST', headers: {'S-ome': 'Header'}}); - $http({url: '/url', method: 'PUT', headers: {'Another': 'Header'}}); - $http({url: '/url', method: 'DELETE', headers: {}}); - $http({url: '/url', method: 'GET', xsrfHeaderName: 'aHeader'}); - $http({url: '/url', method: 'GET', xsrfCookieName: 'aCookie'}); - - $httpBackend.flush(); - })); - it('should send execute result if header value is function', function() { var headerConfig = {'Accept': function() { return 'Rewritten'; }}; @@ -841,20 +797,6 @@ describe('$http', function() { expect(config.foo).toBeUndefined(); }); - - it('should check the cache before checking the XSRF cookie', inject(function($cacheFactory) { - var testCache = $cacheFactory('testCache'); - - spyOn(testCache, 'get').and.callFake(function() { - mockedCookies['XSRF-TOKEN'] = 'foo'; - }); - - $httpBackend.expect('GET', '/url', undefined, function(headers) { - return headers['X-XSRF-TOKEN'] === 'foo'; - }).respond(''); - $http({url: '/url', method: 'GET', cache: testCache}); - $httpBackend.flush(); - })); }); @@ -2266,6 +2208,148 @@ describe('$http', function() { }); + describe('XSRF', function() { + var $http; + var $httpBackend; + + beforeEach(module(function($httpProvider) { + $httpProvider.xsrfWhitelistedOrigins.push( + 'https://whitelisted.example.com', + 'https://whitelisted2.example.com:1337/ignored/path'); + })); + + beforeEach(inject(function(_$http_, _$httpBackend_) { + $http = _$http_; + $httpBackend = _$httpBackend_; + })); + + + it('should set the XSRF cookie into an XSRF header', function() { + function checkXsrf(secret, header) { + return function checkHeaders(headers) { + return headers[header || 'X-XSRF-TOKEN'] === secret; + }; + } + + mockedCookies['XSRF-TOKEN'] = 'secret'; + mockedCookies['aCookie'] = 'secret2'; + $httpBackend.expect('GET', '/url', null, checkXsrf('secret')).respond(null); + $httpBackend.expect('POST', '/url', null, checkXsrf('secret')).respond(null); + $httpBackend.expect('PUT', '/url', null, checkXsrf('secret')).respond(null); + $httpBackend.expect('DELETE', '/url', null, checkXsrf('secret')).respond(null); + $httpBackend.expect('GET', '/url', null, checkXsrf('secret', 'aHeader')).respond(null); + $httpBackend.expect('GET', '/url', null, checkXsrf('secret2')).respond(null); + + $http({method: 'GET', url: '/url'}); + $http({method: 'POST', url: '/url', headers: {'S-ome': 'Header'}}); + $http({method: 'PUT', url: '/url', headers: {'Another': 'Header'}}); + $http({method: 'DELETE', url: '/url', headers: {}}); + $http({method: 'GET', url: '/url', xsrfHeaderName: 'aHeader'}); + $http({method: 'GET', url: '/url', xsrfCookieName: 'aCookie'}); + + $httpBackend.flush(); + }); + + + it('should support setting a default XSRF cookie/header name', function() { + $http.defaults.xsrfCookieName = 'aCookie'; + $http.defaults.xsrfHeaderName = 'aHeader'; + + function checkHeaders(headers) { + return headers.aHeader === 'secret'; + } + + mockedCookies.aCookie = 'secret'; + $httpBackend.expect('GET', '/url', null, checkHeaders).respond(null); + + $http.get('/url'); + + $httpBackend.flush(); + }); + + + it('should support overriding the default XSRF cookie/header name per request', function() { + $http.defaults.xsrfCookieName = 'aCookie'; + $http.defaults.xsrfHeaderName = 'aHeader'; + + function checkHeaders(headers) { + return headers.anotherHeader === 'anotherSecret'; + } + + mockedCookies.anotherCookie = 'anotherSecret'; + $httpBackend.expect('GET', '/url', null, checkHeaders).respond(null); + + $http.get('/url', { + xsrfCookieName: 'anotherCookie', + xsrfHeaderName: 'anotherHeader' + }); + + $httpBackend.flush(); + }); + + + it('should check the cache before checking the XSRF cookie', inject(function($cacheFactory) { + function checkHeaders(headers) { + return headers['X-XSRF-TOKEN'] === 'foo'; + } + function setCookie() { + mockedCookies['XSRF-TOKEN'] = 'foo'; + } + + var testCache = $cacheFactory('testCache'); + spyOn(testCache, 'get').and.callFake(setCookie); + + $httpBackend.expect('GET', '/url', null, checkHeaders).respond(null); + $http.get('/url', {cache: testCache}); + + $httpBackend.flush(); + })); + + + it('should not set an XSRF header for cross-domain requests', function() { + function checkHeaders(headers) { + return isUndefined(headers['X-XSRF-TOKEN']); + } + var requestUrls = [ + 'https://api.example.com/path', + 'http://whitelisted.example.com', + 'https://whitelisted2.example.com:1338' + ]; + + mockedCookies['XSRF-TOKEN'] = 'secret'; + + requestUrls.forEach(function(url) { + $httpBackend.expect('GET', url, null, checkHeaders).respond(null); + $http.get(url); + $httpBackend.flush(); + }); + }); + + + it('should set an XSRF header for cross-domain requests to whitelisted origins', + inject(function($browser) { + function checkHeaders(headers) { + return headers['X-XSRF-TOKEN'] === 'secret'; + } + var currentUrl = 'https://example.com/path'; + var requestUrls = [ + 'https://whitelisted.example.com/path', + 'https://whitelisted2.example.com:1337/path' + ]; + + $browser.url(currentUrl); + mockedCookies['XSRF-TOKEN'] = 'secret'; + + requestUrls.forEach(function(url) { + $httpBackend.expect('GET', url, null, checkHeaders).respond(null); + $http.get(url); + $httpBackend.flush(); + }); + }) + ); + }); + + it('should pass timeout, withCredentials and responseType', function() { var $httpBackend = jasmine.createSpy('$httpBackend'); @@ -2483,7 +2567,6 @@ describe('$http param serializers', function() { 'a%5B%5D=b&a%5B%5D=c&d%5B0%5D%5Be%5D=f&d%5B0%5D%5Bg%5D=h&d%5B%5D=i&d%5B2%5D%5Bj%5D=k'); //a[]=b&a[]=c&d[0][e]=f&d[0][g]=h&d[]=i&d[2][j]=k }); - it('should serialize `null` and `undefined` elements as empty', function() { expect(jqrSer({items:['foo', 'bar', null, undefined, 'baz'], x: null, y: undefined})).toEqual( 'items%5B%5D=foo&items%5B%5D=bar&items%5B%5D=&items%5B%5D=&items%5B%5D=baz&x=&y='); diff --git a/test/ng/parseSpec.js b/test/ng/parseSpec.js index 5142bc22a7ed..985c9eb2c530 100644 --- a/test/ng/parseSpec.js +++ b/test/ng/parseSpec.js @@ -2840,6 +2840,46 @@ describe('parser', function() { expect(filterCalled).toBe(true); }); + it('should not be invoked unless the input/arguments change within literals', function() { + var filterCalls = []; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls.push(input); + return input; + })); + + scope.$watch('[(a | foo:b:1), undefined]'); + scope.a = 0; + scope.$digest(); + expect(filterCalls).toEqual([0]); + + scope.$digest(); + expect(filterCalls).toEqual([0]); + + scope.a++; + scope.$digest(); + expect(filterCalls).toEqual([0, 1]); + }); + + it('should not be invoked unless the input/arguments change within literals (one-time)', function() { + var filterCalls = []; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls.push(input); + return input; + })); + + scope.$watch('::[(a | foo:b:1), undefined]'); + scope.a = 0; + scope.$digest(); + expect(filterCalls).toEqual([0]); + + scope.$digest(); + expect(filterCalls).toEqual([0]); + + scope.a++; + scope.$digest(); + expect(filterCalls).toEqual([0, 1]); + }); + it('should always be invoked if they are marked as having $stateful', function() { var filterCalled = false; $filterProvider.register('foo', valueFn(extend(function(input) { @@ -2883,6 +2923,52 @@ describe('parser', function() { expect(watcherCalls).toBe(1); })); + it('should ignore changes within nested objects', function() { + var watchCalls = []; + scope.$watch('[a]', function(a) { watchCalls.push(a[0]); }); + scope.a = 0; + scope.$digest(); + expect(watchCalls).toEqual([0]); + + scope.$digest(); + expect(watchCalls).toEqual([0]); + + scope.a++; + scope.$digest(); + expect(watchCalls).toEqual([0, 1]); + + scope.a = {}; + scope.$digest(); + expect(watchCalls).toEqual([0, 1, {}]); + + scope.a.foo = 42; + scope.$digest(); + expect(watchCalls).toEqual([0, 1, {foo: 42}]); + }); + + it('should ignore changes within nested objects (one-time)', function() { + var watchCalls = []; + scope.$watch('::[a, undefined]', function(a) { watchCalls.push(a[0]); }); + scope.a = 0; + scope.$digest(); + expect(watchCalls).toEqual([0]); + + scope.$digest(); + expect(watchCalls).toEqual([0]); + + scope.a++; + scope.$digest(); + expect(watchCalls).toEqual([0, 1]); + + scope.a = {}; + scope.$digest(); + expect(watchCalls).toEqual([0, 1, {}]); + + scope.a.foo = 42; + scope.$digest(); + expect(watchCalls).toEqual([0, 1, {foo: 42}]); + }); + describe('with non-primitive input', function() { describe('that does NOT support valueOf()', function() { diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index eea9e824ae66..e2f33ee80bba 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -1462,6 +1462,49 @@ describe('Scope', function() { })); + it('should pass same group instance on first call (no expressions)', function() { + var newValues; + var oldValues; + scope.$watchGroup([], function(n, o) { + newValues = n; + oldValues = o; + }); + + scope.$apply(); + expect(newValues).toBe(oldValues); + }); + + + it('should pass same group instance on first call (single expression)', function() { + var newValues; + var oldValues; + scope.$watchGroup(['a'], function(n, o) { + newValues = n; + oldValues = o; + }); + + scope.$apply(); + expect(newValues).toBe(oldValues); + + scope.$apply('a = 1'); + expect(newValues).not.toBe(oldValues); + }); + + it('should pass same group instance on first call (multiple expressions)', function() { + var newValues; + var oldValues; + scope.$watchGroup(['a', 'b'], function(n, o) { + newValues = n; + oldValues = o; + }); + + scope.$apply(); + expect(newValues).toBe(oldValues); + + scope.$apply('a = 1'); + expect(newValues).not.toBe(oldValues); + }); + it('should detect a change to any one expression in the group', function() { scope.$watchGroup(['a', 'b'], function(values, oldValues, s) { expect(s).toBe(scope); @@ -1542,6 +1585,72 @@ describe('Scope', function() { expect(log).toEqual(''); }); + it('should have each individual old value equal to new values of previous watcher invocation', function() { + var newValues; + var oldValues; + scope.$watchGroup(['a', 'b'], function(n, o) { + newValues = n.slice(); + oldValues = o.slice(); + }); + + scope.$apply(); //skip the initial invocation + + scope.$apply('a = 1'); + expect(newValues).toEqual([1, undefined]); + expect(oldValues).toEqual([undefined, undefined]); + + scope.$apply('a = 2'); + expect(newValues).toEqual([2, undefined]); + expect(oldValues).toEqual([1, undefined]); + + scope.$apply('b = 3'); + expect(newValues).toEqual([2, 3]); + expect(oldValues).toEqual([2, undefined]); + + scope.$apply('a = b = 4'); + expect(newValues).toEqual([4, 4]); + expect(oldValues).toEqual([2, 3]); + + scope.$apply('a = 5'); + expect(newValues).toEqual([5, 4]); + expect(oldValues).toEqual([4, 4]); + + scope.$apply('b = 6'); + expect(newValues).toEqual([5, 6]); + expect(oldValues).toEqual([5, 4]); + }); + + + it('should have each individual old value equal to new values of previous watcher invocation, with modifications from other watchers', function() { + scope.$watch('a', function() { scope.b++; }); + scope.$watch('b', function() { scope.c++; }); + + var newValues; + var oldValues; + scope.$watchGroup(['a', 'b', 'c'], function(n, o) { + newValues = n.slice(); + oldValues = o.slice(); + }); + + scope.$apply(); //skip the initial invocation + + scope.$apply('a = b = c = 1'); + expect(newValues).toEqual([1, 2, 2]); + expect(oldValues).toEqual([undefined, NaN, NaN]); + + scope.$apply('a = 3'); + expect(newValues).toEqual([3, 3, 3]); + expect(oldValues).toEqual([1, 2, 2]); + + scope.$apply('b = 5'); + expect(newValues).toEqual([3, 5, 4]); + expect(oldValues).toEqual([3, 3, 3]); + + scope.$apply('c = 7'); + expect(newValues).toEqual([3, 5, 7]); + expect(oldValues).toEqual([3, 5, 4]); + }); + it('should remove all watchers once one-time/constant bindings are stable', function() { //empty scope.$watchGroup([], noop); diff --git a/test/ng/urlUtilsSpec.js b/test/ng/urlUtilsSpec.js index a13f3661fc5f..ebd864076623 100644 --- a/test/ng/urlUtilsSpec.js +++ b/test/ng/urlUtilsSpec.js @@ -2,10 +2,20 @@ describe('urlUtils', function() { describe('urlResolve', function() { + it('should returned already parsed URLs unchanged', function() { + var urlObj = urlResolve('/foo?bar=baz#qux'); + expect(urlResolve(urlObj)).toBe(urlObj); + expect(urlResolve(true)).toBe(true); + expect(urlResolve(null)).toBeNull(); + expect(urlResolve(undefined)).toBeUndefined(); + }); + + it('should normalize a relative url', function() { expect(urlResolve('foo').href).toMatch(/^https?:\/\/[^/]+\/foo$/); }); + it('should parse relative URL into component pieces', function() { var parsed = urlResolve('foo'); expect(parsed.href).toMatch(/https?:\/\//); @@ -23,28 +33,116 @@ describe('urlUtils', function() { }); }); - describe('isSameOrigin and urlIsSameOriginAsBaseUrl', function() { - it('should support various combinations of urls - both string and parsed', inject(function($document) { - function expectIsSameOrigin(url, expectedValue) { - expect(urlIsSameOrigin(url)).toBe(expectedValue); - expect(urlIsSameOrigin(urlResolve(url))).toBe(expectedValue); - - // urlIsSameOriginAsBaseUrl() should behave the same as urlIsSameOrigin() by default. - // Behavior when there is a non-default base URL or when the base URL changes dynamically - // is tested in the end-to-end tests in e2e/tests/base-tag.spec.js. - expect(urlIsSameOriginAsBaseUrl(url)).toBe(expectedValue); - expect(urlIsSameOriginAsBaseUrl(urlResolve(url))).toBe(expectedValue); - } - expectIsSameOrigin('path', true); - var origin = urlResolve($document[0].location.href); - expectIsSameOrigin('//' + origin.host + '/path', true); - // Different domain. - expectIsSameOrigin('http://example.com/path', false); - // Auto fill protocol. - expectIsSameOrigin('//example.com/path', false); - // Should not match when the ports are different. - // This assumes that the test is *not* running on port 22 (very unlikely). - expectIsSameOrigin('//' + origin.hostname + ':22/path', false); - })); + + describe('urlIsSameOrigin and urlIsSameOriginAsBaseUrl', function() { + it('should support various combinations of urls - both string and parsed', + inject(function($document) { + function expectIsSameOrigin(url, expectedValue) { + expect(urlIsSameOrigin(url)).toBe(expectedValue); + expect(urlIsSameOrigin(urlResolve(url))).toBe(expectedValue); + + // urlIsSameOriginAsBaseUrl() should behave the same as urlIsSameOrigin() by default. + // Behavior when there is a non-default base URL or when the base URL changes dynamically + // is tested in the end-to-end tests in e2e/tests/base-tag.spec.js. + expect(urlIsSameOriginAsBaseUrl(url)).toBe(expectedValue); + expect(urlIsSameOriginAsBaseUrl(urlResolve(url))).toBe(expectedValue); + } + + expectIsSameOrigin('path', true); + + var origin = urlResolve($document[0].location.href); + expectIsSameOrigin('//' + origin.host + '/path', true); + + // Different domain. + expectIsSameOrigin('http://example.com/path', false); + + // Auto fill protocol. + expectIsSameOrigin('//example.com/path', false); + + // Should not match when the ports are different. + // This assumes that the test is *not* running on port 22 (very unlikely). + expectIsSameOrigin('//' + origin.hostname + ':22/path', false); + }) + ); + }); + + + describe('urlIsAllowedOriginFactory', function() { + var origin = urlResolve(window.location.href); + var urlIsAllowedOrigin; + + beforeEach(function() { + urlIsAllowedOrigin = urlIsAllowedOriginFactory([ + 'https://foo.com/', + origin.protocol + '://bar.com:1337/' + ]); + }); + + + it('should implicitly allow the current origin', function() { + expect(urlIsAllowedOrigin('path')).toBe(true); + }); + + + it('should check against the list of whitelisted origins', function() { + expect(urlIsAllowedOrigin('https://foo.com/path')).toBe(true); + expect(urlIsAllowedOrigin(origin.protocol + '://bar.com:1337/path')).toBe(true); + expect(urlIsAllowedOrigin('https://baz.com:1337/path')).toBe(false); + expect(urlIsAllowedOrigin('https://qux.com/path')).toBe(false); + }); + + + it('should support both strings and parsed URL objects', function() { + expect(urlIsAllowedOrigin('path')).toBe(true); + expect(urlIsAllowedOrigin(urlResolve('path'))).toBe(true); + expect(urlIsAllowedOrigin('https://foo.com/path')).toBe(true); + expect(urlIsAllowedOrigin(urlResolve('https://foo.com/path'))).toBe(true); + }); + + + it('should return true only if the origins (protocol, hostname, post) match', function() { + var differentProtocol = (origin.protocol !== 'http') ? 'http' : 'https'; + var differentPort = (parseInt(origin.port, 10) || 0) + 1; + var url; + + + // Relative path + url = 'path'; + expect(urlIsAllowedOrigin(url)).toBe(true); + + + // Same origin + url = origin.protocol + '://' + origin.host + '/path'; + expect(urlIsAllowedOrigin(url)).toBe(true); + + // Same origin - implicit protocol + url = '//' + origin.host + '/path'; + expect(urlIsAllowedOrigin(url)).toBe(true); + + // Same origin - different protocol + url = differentProtocol + '://' + origin.host + '/path'; + expect(urlIsAllowedOrigin(url)).toBe(false); + + // Same origin - different port + url = origin.protocol + '://' + origin.hostname + ':' + differentPort + '/path'; + expect(urlIsAllowedOrigin(url)).toBe(false); + + + // Allowed origin + url = origin.protocol + '://bar.com:1337/path'; + expect(urlIsAllowedOrigin(url)).toBe(true); + + // Allowed origin - implicit protocol + url = '//bar.com:1337/path'; + expect(urlIsAllowedOrigin(url)).toBe(true); + + // Allowed origin - different protocol + url = differentProtocol + '://bar.com:1337/path'; + expect(urlIsAllowedOrigin(url)).toBe(false); + + // Allowed origin - different port + url = origin.protocol + '://bar.com:1338/path'; + expect(urlIsAllowedOrigin(url)).toBe(false); + }); }); });