Skip to content

Commit c578f8c

Browse files
committed
Added XSRF prevention logic to $xhr service
1 parent 5b05c0d commit c578f8c

File tree

6 files changed

+160
-32
lines changed

6 files changed

+160
-32
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<a name="0.9.13"><a/>
22
# <angular/> 0.9.13 curdling-stare (in-progress) #
33

4+
### New Features
5+
- Added XSRF prevention logic to $xhr service
6+
7+
48
### Bug Fixes
59
- Fixed cookies which contained unescaped '=' would not show up in cookie service.
610
- Consider all 2xx responses as OK, not just 200

src/Browser.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ var XHR = window.XMLHttpRequest || function () {
77
try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e3) {}
88
throw new Error("This browser does not support XMLHttpRequest.");
99
};
10+
var XHR_HEADERS = {
11+
"Content-Type": "application/x-www-form-urlencoded",
12+
"Accept": "application/json, text/plain, */*",
13+
"X-Requested-With": "XMLHttpRequest"
14+
};
1015

1116
/**
1217
* @private
@@ -72,11 +77,18 @@ function Browser(window, document, body, XHR, $log) {
7277
* @param {string} url Requested url
7378
* @param {?string} post Post data to send (null if nothing to post)
7479
* @param {function(number, string)} callback Function that will be called on response
80+
* @param {object=} header additional HTTP headers to send with XHR.
81+
* Standard headers are:
82+
* <ul>
83+
* <li><tt>Content-Type</tt>: <tt>application/x-www-form-urlencoded</tt></li>
84+
* <li><tt>Accept</tt>: <tt>application/json, text/plain, &#42;/&#42;</tt></li>
85+
* <li><tt>X-Requested-With</tt>: <tt>XMLHttpRequest</tt></li>
86+
* </ul>
7587
*
7688
* @description
7789
* Send ajax request
7890
*/
79-
self.xhr = function(method, url, post, callback) {
91+
self.xhr = function(method, url, post, callback, headers) {
8092
outstandingRequestCount ++;
8193
if (lowercase(method) == 'json') {
8294
var callbackId = "angular_" + Math.random() + '_' + (idCounter++);
@@ -92,9 +104,9 @@ function Browser(window, document, body, XHR, $log) {
92104
} else {
93105
var xhr = new XHR();
94106
xhr.open(method, url, true);
95-
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
96-
xhr.setRequestHeader("Accept", "application/json, text/plain, */*");
97-
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
107+
forEach(extend(XHR_HEADERS, headers || {}), function(value, key){
108+
if (value) xhr.setRequestHeader(key, value);
109+
});
98110
xhr.onreadystatechange = function() {
99111
if (xhr.readyState == 4) {
100112
completeOutstandingRequest(callback, xhr.status || 200, xhr.responseText);

src/angular-mocks.js

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -101,28 +101,27 @@ function MockBrowser() {
101101
};
102102

103103

104-
self.xhr = function(method, url, data, callback) {
105-
if (angular.isFunction(data)) {
106-
callback = data;
107-
data = null;
108-
}
104+
self.xhr = function(method, url, data, callback, headers) {
105+
headers = headers || {};
109106
if (data && angular.isObject(data)) data = angular.toJson(data);
110107
if (data && angular.isString(data)) url += "|" + data;
111108
var expect = expectations[method] || {};
112-
var response = expect[url];
113-
if (!response) {
114-
throw {
115-
message: "Unexpected request for method '" + method + "' and url '" + url + "'.",
116-
name: "Unexpected Request"
117-
};
109+
var expectation = expect[url];
110+
if (!expectation) {
111+
throw new Error("Unexpected request for method '" + method + "' and url '" + url + "'.");
118112
}
119113
requests.push(function(){
120-
callback(response.code, response.response);
114+
forEach(expectation.headers, function(value, key){
115+
if (headers[key] !== value) {
116+
throw new Error("Missing HTTP request header: " + key + ": " + value);
117+
}
118+
});
119+
callback(expectation.code, expectation.response);
121120
});
122121
};
123122
self.xhr.expectations = expectations;
124123
self.xhr.requests = requests;
125-
self.xhr.expect = function(method, url, data) {
124+
self.xhr.expect = function(method, url, data, headers) {
126125
if (data && angular.isObject(data)) data = angular.toJson(data);
127126
if (data && angular.isString(data)) url += "|" + data;
128127
var expect = expectations[method] || (expectations[method] = {});
@@ -132,7 +131,7 @@ function MockBrowser() {
132131
response = code;
133132
code = 200;
134133
}
135-
expect[url] = {code:code, response:response};
134+
expect[url] = {code:code, response:response, headers: headers || {}};
136135
}
137136
};
138137
};

src/service/xhr.js

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,71 @@
33
* @ngdoc service
44
* @name angular.service.$xhr
55
* @function
6-
* @requires $browser
7-
* @requires $xhr.error
8-
* @requires $log
6+
* @requires $browser $xhr delegates all XHR requests to the `$browser.xhr()`. A mock version
7+
* of the $browser exists which allows setting expectaitions on XHR requests
8+
* in your tests
9+
* @requires $xhr.error $xhr delegates all non `2xx` response code to this service.
10+
* @requires $log $xhr delegates all exceptions to `$log.error()`.
11+
* @requires $updateView After a server response the view needs to be updated for data-binding to
12+
* take effect.
913
*
1014
* @description
11-
* Generates an XHR request. The $xhr service adds error handling then delegates all requests to
12-
* {@link angular.service.$browser $browser.xhr()}.
15+
* Generates an XHR request. The $xhr service delegates all requests to
16+
* {@link angular.service.$browser $browser.xhr()} and adds error handling and security features.
17+
* While $xhr service provides nicer api than raw XmlHttpRequest, it is still considered a lower
18+
* level api in angular. For a higher level abstraction that utilizes `$xhr`, please check out the
19+
* {@link angular.service$resource $resource} service.
20+
*
21+
* # Error handling
22+
* All XHR responses with response codes other then `2xx` are delegated to
23+
* {@link angular.service.$xhr.error $xhr.error}. The `$xhr.error` can intercept the request
24+
* and process it in application specific way, or resume normal execution by calling the
25+
* request callback method.
26+
*
27+
* # Security Considerations
28+
* When designing web applications your design needs to consider security threats from
29+
* {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx
30+
* JSON Vulnerability} and {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF}.
31+
* Both server and the client must cooperate in order to eliminate these threats. Angular comes
32+
* pre-configured with strategies that address these issues, but for this to work backend server
33+
* cooperation is required.
34+
*
35+
* ## JSON Vulnerability Protection
36+
* A {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx
37+
* JSON Vulnerability} allows third party web-site to turn your JSON resource URL into
38+
* {@link http://en.wikipedia.org/wiki/JSON#JSONP JSONP} request under some conditions. To
39+
* counter this your server can prefix all JSON requests with following string `")]}',\n"`.
40+
* Angular will automatically strip the prefix before processing it as JSON.
41+
*
42+
* For example if your server needs to return:
43+
* <pre>
44+
* ['one','two']
45+
* </pre>
46+
*
47+
* which is vulnerable to attack, your server can return:
48+
* <pre>
49+
* )]}',
50+
* ['one','two']
51+
* </pre>
52+
*
53+
* angular will strip the prefix, before processing the JSON.
54+
*
55+
*
56+
* ## Cross Site Request Forgery (XSRF) Protection
57+
* {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF} is a technique by which an
58+
* unauthorized site can gain your user's private data. Angular provides following mechanism to
59+
* counter XSRF. When performing XHR requests, the $xhr service reads a token from a cookie
60+
* called `XSRF-TOKEN` and sets it as the HTTP header `X-XSRF-TOKEN`. Since only JavaScript that
61+
* runs on your domain could read the cookie, your server can be assured that the XHR came from
62+
* JavaScript running on your domain.
63+
*
64+
* To take advantage of this, your server needs to set a token in a JavaScript readable session
65+
* cookie called `XSRF-TOKEN` on first HTTP GET request. On subsequent non-GET requests the server
66+
* can verify that the cookie matches `X-XSRF-TOKEN` HTTP header, and therefore be sure that only
67+
* JavaScript running on your domain could have read the token. The token must be unique for each
68+
* user and must be verifiable by the server (to prevent the JavaScript making up its own tokens).
69+
* We recommend that the token is a digest of your site's authentication cookie with
70+
* {@link http://en.wikipedia.org/wiki/Rainbow_table salt for added security}.
1371
*
1472
* @param {string} method HTTP method to use. Valid values are: `GET`, `POST`, `PUT`, `DELETE`, and
1573
* `JSON`. `JSON` is a special case which causes a
@@ -67,8 +125,7 @@
67125
</doc:source>
68126
</doc:example>
69127
*/
70-
angularServiceInject('$xhr', function($browser, $error, $log){
71-
var self = this;
128+
angularServiceInject('$xhr', function($browser, $error, $log, $updateView){
72129
return function(method, url, post, callback){
73130
if (isFunction(post)) {
74131
callback = post;
@@ -77,6 +134,7 @@ angularServiceInject('$xhr', function($browser, $error, $log){
77134
if (post && isObject(post)) {
78135
post = toJson(post);
79136
}
137+
80138
$browser.xhr(method, url, post, function(code, response){
81139
try {
82140
if (isString(response)) {
@@ -95,8 +153,10 @@ angularServiceInject('$xhr', function($browser, $error, $log){
95153
} catch (e) {
96154
$log.error(e);
97155
} finally {
98-
self.$eval();
156+
$updateView();
99157
}
158+
}, {
159+
'X-XSRF-TOKEN': $browser.cookies()['XSRF-TOKEN']
100160
});
101161
};
102-
}, ['$browser', '$xhr.error', '$log']);
162+
}, ['$browser', '$xhr.error', '$log', '$updateView']);

test/BrowserSpecs.js

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,20 @@ describe('browser', function(){
2424

2525
var fakeBody = {append: function(node){scripts.push(node);}};
2626

27-
var fakeXhr = function(){
27+
var FakeXhr = function(){
2828
xhr = this;
29-
this.open = noop;
30-
this.setRequestHeader = noop;
31-
this.send = noop;
29+
this.open = function(method, url, async){
30+
xhr.method = method;
31+
xhr.url = url;
32+
xhr.async = async;
33+
xhr.headers = {};
34+
};
35+
this.setRequestHeader = function(key, value){
36+
xhr.headers[key] = value;
37+
};
38+
this.send = function(post){
39+
xhr.post = post;
40+
};
3241
};
3342

3443
logs = {log:[], warn:[], info:[], error:[]};
@@ -38,7 +47,7 @@ describe('browser', function(){
3847
info: function() { logs.info.push(slice.call(arguments)); },
3948
error: function() { logs.error.push(slice.call(arguments)); }};
4049

41-
browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, fakeXhr,
50+
browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, FakeXhr,
4251
fakeLog);
4352
});
4453

@@ -85,6 +94,33 @@ describe('browser', function(){
8594
expect(typeof fakeWindow[url[1]]).toEqual('undefined');
8695
});
8796
});
97+
98+
it('should set headers for all requests', function(){
99+
var code, response, headers = {};
100+
browser.xhr('METHOD', 'URL', 'POST', function(c,r){
101+
code = c;
102+
response = r;
103+
}, {'X-header': 'value'});
104+
105+
expect(xhr.method).toEqual('METHOD');
106+
expect(xhr.url).toEqual('URL');
107+
expect(xhr.post).toEqual('POST');
108+
expect(xhr.headers).toEqual({
109+
"Content-Type": "application/x-www-form-urlencoded",
110+
"Accept": "application/json, text/plain, */*",
111+
"X-Requested-With": "XMLHttpRequest",
112+
"X-header":"value"
113+
});
114+
115+
xhr.status = 202;
116+
xhr.responseText = 'RESPONSE';
117+
xhr.readyState = 4;
118+
xhr.onreadystatechange();
119+
120+
expect(code).toEqual(202);
121+
expect(response).toEqual('RESPONSE');
122+
});
123+
88124
});
89125

90126

test/service/xhrSpec.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,21 @@ describe('$xhr', function() {
101101

102102
expect(response).toEqual([1, 'abc', {foo:'bar'}]);
103103
});
104+
105+
describe('xsrf', function(){
106+
it('should copy the XSRF cookie into a XSRF Header', function(){
107+
var code, response;
108+
$browserXhr
109+
.expectPOST('URL', 'DATA', {'X-XSRF-TOKEN': 'secret'})
110+
.respond(234, 'OK');
111+
$browser.cookies('XSRF-TOKEN', 'secret');
112+
$xhr('POST', 'URL', 'DATA', function(c, r){
113+
code = c;
114+
response = r;
115+
});
116+
$browserXhr.flush();
117+
expect(code).toEqual(234);
118+
expect(response).toEqual('OK');
119+
});
120+
});
104121
});

0 commit comments

Comments
 (0)