diff options
| author | Misko Hevery | 2011-03-10 13:50:00 -0800 |
|---|---|---|
| committer | Misko Hevery | 2011-03-11 14:16:53 -0800 |
| commit | c578f8c3ed0ca23b03ccde146cb13cfaf24f17cd (patch) | |
| tree | 12182c82ee4411091b6d92f81829dd52f8792e27 | |
| parent | 5b05c0de036f77db0cc493082e21b1451c6b9a5f (diff) | |
| download | angular.js-c578f8c3ed0ca23b03ccde146cb13cfaf24f17cd.tar.bz2 | |
Added XSRF prevention logic to $xhr service
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | src/Browser.js | 20 | ||||
| -rw-r--r-- | src/angular-mocks.js | 27 | ||||
| -rw-r--r-- | src/service/xhr.js | 78 | ||||
| -rw-r--r-- | test/BrowserSpecs.js | 46 | ||||
| -rw-r--r-- | test/service/xhrSpec.js | 17 |
6 files changed, 160 insertions, 32 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index ab711085..0e6b2c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ <a name="0.9.13"><a/> # <angular/> 0.9.13 curdling-stare (in-progress) # +### New Features +- Added XSRF prevention logic to $xhr service + + ### Bug Fixes - Fixed cookies which contained unescaped '=' would not show up in cookie service. - Consider all 2xx responses as OK, not just 200 diff --git a/src/Browser.js b/src/Browser.js index fe6220ed..abafb2a5 100644 --- a/src/Browser.js +++ b/src/Browser.js @@ -7,6 +7,11 @@ var XHR = window.XMLHttpRequest || function () { try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e3) {} throw new Error("This browser does not support XMLHttpRequest."); }; +var XHR_HEADERS = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json, text/plain, */*", + "X-Requested-With": "XMLHttpRequest" +}; /** * @private @@ -72,11 +77,18 @@ function Browser(window, document, body, XHR, $log) { * @param {string} url Requested url * @param {?string} post Post data to send (null if nothing to post) * @param {function(number, string)} callback Function that will be called on response + * @param {object=} header additional HTTP headers to send with XHR. + * Standard headers are: + * <ul> + * <li><tt>Content-Type</tt>: <tt>application/x-www-form-urlencoded</tt></li> + * <li><tt>Accept</tt>: <tt>application/json, text/plain, */*</tt></li> + * <li><tt>X-Requested-With</tt>: <tt>XMLHttpRequest</tt></li> + * </ul> * * @description * Send ajax request */ - self.xhr = function(method, url, post, callback) { + self.xhr = function(method, url, post, callback, headers) { outstandingRequestCount ++; if (lowercase(method) == 'json') { var callbackId = "angular_" + Math.random() + '_' + (idCounter++); @@ -92,9 +104,9 @@ function Browser(window, document, body, XHR, $log) { } else { var xhr = new XHR(); xhr.open(method, url, true); - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); - xhr.setRequestHeader("Accept", "application/json, text/plain, */*"); - xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + forEach(extend(XHR_HEADERS, headers || {}), function(value, key){ + if (value) xhr.setRequestHeader(key, value); + }); xhr.onreadystatechange = function() { if (xhr.readyState == 4) { completeOutstandingRequest(callback, xhr.status || 200, xhr.responseText); diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 762148fd..558e71e3 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -101,28 +101,27 @@ function MockBrowser() { }; - self.xhr = function(method, url, data, callback) { - if (angular.isFunction(data)) { - callback = data; - data = null; - } + self.xhr = function(method, url, data, callback, headers) { + headers = headers || {}; if (data && angular.isObject(data)) data = angular.toJson(data); if (data && angular.isString(data)) url += "|" + data; var expect = expectations[method] || {}; - var response = expect[url]; - if (!response) { - throw { - message: "Unexpected request for method '" + method + "' and url '" + url + "'.", - name: "Unexpected Request" - }; + var expectation = expect[url]; + if (!expectation) { + throw new Error("Unexpected request for method '" + method + "' and url '" + url + "'."); } requests.push(function(){ - callback(response.code, response.response); + forEach(expectation.headers, function(value, key){ + if (headers[key] !== value) { + throw new Error("Missing HTTP request header: " + key + ": " + value); + } + }); + callback(expectation.code, expectation.response); }); }; self.xhr.expectations = expectations; self.xhr.requests = requests; - self.xhr.expect = function(method, url, data) { + self.xhr.expect = function(method, url, data, headers) { if (data && angular.isObject(data)) data = angular.toJson(data); if (data && angular.isString(data)) url += "|" + data; var expect = expectations[method] || (expectations[method] = {}); @@ -132,7 +131,7 @@ function MockBrowser() { response = code; code = 200; } - expect[url] = {code:code, response:response}; + expect[url] = {code:code, response:response, headers: headers || {}}; } }; }; diff --git a/src/service/xhr.js b/src/service/xhr.js index b1b57a82..1de72b22 100644 --- a/src/service/xhr.js +++ b/src/service/xhr.js @@ -3,13 +3,71 @@ * @ngdoc service * @name angular.service.$xhr * @function - * @requires $browser - * @requires $xhr.error - * @requires $log + * @requires $browser $xhr delegates all XHR requests to the `$browser.xhr()`. A mock version + * of the $browser exists which allows setting expectaitions on XHR requests + * in your tests + * @requires $xhr.error $xhr delegates all non `2xx` response code to this service. + * @requires $log $xhr delegates all exceptions to `$log.error()`. + * @requires $updateView After a server response the view needs to be updated for data-binding to + * take effect. * * @description - * Generates an XHR request. The $xhr service adds error handling then delegates all requests to - * {@link angular.service.$browser $browser.xhr()}. + * Generates an XHR request. The $xhr service delegates all requests to + * {@link angular.service.$browser $browser.xhr()} and adds error handling and security features. + * While $xhr service provides nicer api than raw XmlHttpRequest, it is still considered a lower + * level api in angular. For a higher level abstraction that utilizes `$xhr`, please check out the + * {@link angular.service$resource $resource} service. + * + * # Error handling + * All XHR responses with response codes other then `2xx` are delegated to + * {@link angular.service.$xhr.error $xhr.error}. The `$xhr.error` can intercept the request + * and process it in application specific way, or resume normal execution by calling the + * request callback method. + * + * # Security Considerations + * When designing web applications your design needs to consider security threats from + * {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx + * JSON Vulnerability} and {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF}. + * Both server and the client must cooperate in order to eliminate these threats. Angular comes + * pre-configured with strategies that address these issues, but for this to work backend server + * cooperation is required. + * + * ## JSON Vulnerability Protection + * A {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx + * JSON Vulnerability} allows third party web-site to turn your JSON resource URL into + * {@link http://en.wikipedia.org/wiki/JSON#JSONP JSONP} request under some conditions. To + * counter this your server can prefix all JSON requests with following string `")]}',\n"`. + * Angular will automatically strip the prefix before processing it as JSON. + * + * For example if your server needs to return: + * <pre> + * ['one','two'] + * </pre> + * + * which is vulnerable to attack, your server can return: + * <pre> + * )]}', + * ['one','two'] + * </pre> + * + * angular will strip the prefix, before processing the JSON. + * + * + * ## Cross Site Request Forgery (XSRF) Protection + * {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF} is a technique by which an + * unauthorized site can gain your user's private data. Angular provides following mechanism to + * counter XSRF. When performing XHR requests, the $xhr service reads a token from a cookie + * called `XSRF-TOKEN` and sets it as the 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. + * + * To take advantage of this, your server needs to set a token in a JavaScript readable session + * cookie called `XSRF-TOKEN` on first HTTP GET request. On subsequent non-GET 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 read the token. The token must be unique for each + * user and must be verifiable by the server (to prevent the JavaScript making up its own tokens). + * We recommend that the token is a digest of your site's authentication cookie with + * {@link http://en.wikipedia.org/wiki/Rainbow_table salt for added security}. * * @param {string} method HTTP method to use. Valid values are: `GET`, `POST`, `PUT`, `DELETE`, and * `JSON`. `JSON` is a special case which causes a @@ -67,8 +125,7 @@ </doc:source> </doc:example> */ -angularServiceInject('$xhr', function($browser, $error, $log){ - var self = this; +angularServiceInject('$xhr', function($browser, $error, $log, $updateView){ return function(method, url, post, callback){ if (isFunction(post)) { callback = post; @@ -77,6 +134,7 @@ angularServiceInject('$xhr', function($browser, $error, $log){ if (post && isObject(post)) { post = toJson(post); } + $browser.xhr(method, url, post, function(code, response){ try { if (isString(response)) { @@ -95,8 +153,10 @@ angularServiceInject('$xhr', function($browser, $error, $log){ } catch (e) { $log.error(e); } finally { - self.$eval(); + $updateView(); } + }, { + 'X-XSRF-TOKEN': $browser.cookies()['XSRF-TOKEN'] }); }; -}, ['$browser', '$xhr.error', '$log']); +}, ['$browser', '$xhr.error', '$log', '$updateView']); diff --git a/test/BrowserSpecs.js b/test/BrowserSpecs.js index 180a7fa8..6951783b 100644 --- a/test/BrowserSpecs.js +++ b/test/BrowserSpecs.js @@ -24,11 +24,20 @@ describe('browser', function(){ var fakeBody = {append: function(node){scripts.push(node);}}; - var fakeXhr = function(){ + var FakeXhr = function(){ xhr = this; - this.open = noop; - this.setRequestHeader = noop; - this.send = noop; + this.open = function(method, url, async){ + xhr.method = method; + xhr.url = url; + xhr.async = async; + xhr.headers = {}; + }; + this.setRequestHeader = function(key, value){ + xhr.headers[key] = value; + }; + this.send = function(post){ + xhr.post = post; + }; }; logs = {log:[], warn:[], info:[], error:[]}; @@ -38,7 +47,7 @@ describe('browser', function(){ info: function() { logs.info.push(slice.call(arguments)); }, error: function() { logs.error.push(slice.call(arguments)); }}; - browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, fakeXhr, + browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, FakeXhr, fakeLog); }); @@ -85,6 +94,33 @@ describe('browser', function(){ expect(typeof fakeWindow[url[1]]).toEqual('undefined'); }); }); + + it('should set headers for all requests', function(){ + var code, response, headers = {}; + browser.xhr('METHOD', 'URL', 'POST', function(c,r){ + code = c; + response = r; + }, {'X-header': 'value'}); + + expect(xhr.method).toEqual('METHOD'); + expect(xhr.url).toEqual('URL'); + expect(xhr.post).toEqual('POST'); + expect(xhr.headers).toEqual({ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json, text/plain, */*", + "X-Requested-With": "XMLHttpRequest", + "X-header":"value" + }); + + xhr.status = 202; + xhr.responseText = 'RESPONSE'; + xhr.readyState = 4; + xhr.onreadystatechange(); + + expect(code).toEqual(202); + expect(response).toEqual('RESPONSE'); + }); + }); diff --git a/test/service/xhrSpec.js b/test/service/xhrSpec.js index 66dbe94d..39bc1c66 100644 --- a/test/service/xhrSpec.js +++ b/test/service/xhrSpec.js @@ -101,4 +101,21 @@ describe('$xhr', function() { expect(response).toEqual([1, 'abc', {foo:'bar'}]); }); + + describe('xsrf', function(){ + it('should copy the XSRF cookie into a XSRF Header', function(){ + var code, response; + $browserXhr + .expectPOST('URL', 'DATA', {'X-XSRF-TOKEN': 'secret'}) + .respond(234, 'OK'); + $browser.cookies('XSRF-TOKEN', 'secret'); + $xhr('POST', 'URL', 'DATA', function(c, r){ + code = c; + response = r; + }); + $browserXhr.flush(); + expect(code).toEqual(234); + expect(response).toEqual('OK'); + }); + }); }); |
