Skip to content

Commit e5fb956

Browse files
committed
Merge pull request #145 from andybons/andybons/prefix
Add attribute and header detection to automatically strip JSON response prefixes
2 parents f804fed + 7d0764a commit e5fb956

File tree

3 files changed

+120
-13
lines changed

3 files changed

+120
-13
lines changed

iron-ajax.html

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,23 @@
283283
notify: true
284284
},
285285

286+
/**
287+
* Prefix to be stripped from a JSON response before parsing it.
288+
*
289+
* In order to prevent an attack using CSRF with Array responses
290+
* (http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx/)
291+
* many backends will mitigate this by prefixing all JSON response bodies
292+
* with a string that would be nonsensical to a JavaScript parser.
293+
*
294+
* In the case where the server returns a prefix via the `X-JSON-Prefix`
295+
* header, this attribute can be omitted as the value of the header will
296+
* supercede the value passed here.
297+
*/
298+
jsonPrefix: {
299+
type: String,
300+
value: ''
301+
},
302+
286303
_boundHandleResponse: {
287304
type: Function,
288305
value: function() {
@@ -292,8 +309,8 @@
292309
},
293310

294311
observers: [
295-
'_requestOptionsChanged(url, method, params.*, headers,' +
296-
'contentType, body, sync, handleAs, withCredentials, timeout, auto)'
312+
'_requestOptionsChanged(url, method, params.*, headers, contentType, ' +
313+
'body, sync, handleAs, jsonPrefix, withCredentials, timeout, auto)'
297314
],
298315

299316
/**
@@ -380,6 +397,7 @@
380397
* body: (ArrayBuffer|ArrayBufferView|Blob|Document|FormData|null|string|undefined|Object),
381398
* headers: (Object|undefined),
382399
* handleAs: (string|undefined),
400+
* jsonPrefix: (string|undefined),
383401
* withCredentials: (boolean|undefined)}}
384402
*/
385403
toRequestOptions: function() {
@@ -390,6 +408,7 @@
390408
body: this.body,
391409
async: !this.sync,
392410
handleAs: this.handleAs,
411+
jsonPrefix: this.jsonPrefix,
393412
withCredentials: this.withCredentials,
394413
timeout: this.timeout
395414
};

iron-request.html

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
notify: true,
5757
readOnly: true,
5858
value: function() {
59-
return null;
59+
return null;
6060
}
6161
},
6262

@@ -177,6 +177,7 @@
177177
* body: (ArrayBuffer|ArrayBufferView|Blob|Document|FormData|null|string|undefined|Object),
178178
* headers: (Object|undefined),
179179
* handleAs: (string|undefined),
180+
* jsonPrefix: (string|undefined),
180181
* withCredentials: (boolean|undefined)}} options -
181182
* url The url to which the request is sent.
182183
* method The HTTP method to use, default is GET.
@@ -204,6 +205,29 @@
204205
});
205206
}.bind(this))
206207

208+
xhr.addEventListener('readystatechange', function () {
209+
if (xhr.readyState === 2 && options.async !== false) {
210+
// Headers have been received.
211+
var jsonPrefix = xhr.getResponseHeader('X-JSON-Prefix') ||
212+
options.jsonPrefix;
213+
var handleAs = options.handleAs;
214+
215+
// If a JSON prefix is present, the responseType must be 'text' or the
216+
// browser won’t be able to parse the response.
217+
if (!!jsonPrefix || !handleAs) {
218+
handleAs = 'text';
219+
}
220+
// In IE, `xhr.responseType` is an empty string when the response
221+
// returns. Hence, caching it as `xhr._responseType`.
222+
xhr.responseType = xhr._responseType = handleAs;
223+
224+
// Cache the JSON prefix, if it exists.
225+
if (!!jsonPrefix) {
226+
xhr._jsonPrefix = jsonPrefix;
227+
}
228+
}
229+
}.bind(this));
230+
207231
xhr.addEventListener('error', function (error) {
208232
this._setErrored(true);
209233
this._updateStatus();
@@ -269,17 +293,10 @@
269293
);
270294
}, this);
271295

272-
var body = this._encodeBodyObject(options.body, headers['content-type']);
273-
274-
// In IE, `xhr.responseType` is an empty string when the response
275-
// returns. Hence, caching it as `xhr._responseType`.
276-
if (options.async !== false) {
277-
xhr.responseType = xhr._responseType = (options.handleAs || 'text');
278-
}
279296
xhr.withCredentials = !!options.withCredentials;
280297
xhr.timeout = options.timeout;
281298

282-
299+
var body = this._encodeBodyObject(options.body, headers['content-type']);
283300

284301
xhr.send(
285302
/** @type {ArrayBuffer|ArrayBufferView|Blob|Document|FormData|
@@ -301,6 +318,15 @@
301318
var xhr = this.xhr;
302319
var responseType = xhr.responseType || xhr._responseType;
303320
var preferResponseText = !this.xhr.responseType;
321+
var prefixHeader = xhr.getResponseHeader('X-JSON-Prefix');
322+
if (prefixHeader) {
323+
// If a JSON prefix header is set, the response must be interpretted as
324+
// text so that the prefix can be stripped. Otherwise the browser will
325+
// try and fail to.
326+
responseType = 'text';
327+
}
328+
var prefixLen = (prefixHeader && prefixHeader.length) ||
329+
(xhr._jsonPrefix && xhr._jsonPrefix.length) || 0;
304330

305331
try {
306332
switch (responseType) {
@@ -315,7 +341,7 @@
315341
// That is to say, we try to parse as JSON, but if anything goes
316342
// wrong return null.
317343
try {
318-
return JSON.parse(xhr.responseText);;
344+
return JSON.parse(xhr.responseText);
319345
} catch (_) {
320346
return null;
321347
}
@@ -329,8 +355,20 @@
329355
case 'arraybuffer':
330356
return xhr.response;
331357
case 'text':
332-
default:
358+
default: {
359+
// If `prefixLen` is set, it implies the response should be parsed
360+
// as JSON once the prefix of length `prefixLen` is stripped from
361+
// it. Emulate the behavior above where null is returned on failure
362+
// to parse.
363+
if (prefixLen) {
364+
try {
365+
return JSON.parse(xhr.responseText.substring(prefixLen));
366+
} catch (_) {
367+
return null;
368+
}
369+
}
333370
return xhr.responseText;
371+
}
334372
}
335373
} catch (e) {
336374
this.rejectCompletes(new Error('Could not parse response. ' + e.message));

test/iron-request.html

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@
4343
'{"success":true}'
4444
]);
4545

46+
server.respondWith('GET', '/responds_to_get_with_prefixed_json', [
47+
200,
48+
jsonResponseHeaders,
49+
'])}while(1);</x>{"success":true}'
50+
]);
51+
52+
server.respondWith('GET', '/responds_to_get_with_prefixed_json_and_header', [
53+
200,
54+
{
55+
'Content-Type': 'application/json',
56+
'X-JSON-Prefix': '])}while(1);</x>'
57+
},
58+
'])}while(1);</x>{"success":true}'
59+
]);
60+
4661
server.respondWith('GET', '/responds_to_get_with_500', [
4762
500,
4863
{},
@@ -136,6 +151,41 @@
136151
});
137152
});
138153

154+
test('setting jsonPrefix correctly strips it from the response', function () {
155+
var options = {
156+
url: '/responds_to_get_with_prefixed_json',
157+
handleAs: 'json',
158+
jsonPrefix: '])}while(1);</x>'
159+
};
160+
161+
request.send(options);
162+
expect(server.requests.length).to.be.equal(1);
163+
expect(server.requests[0].requestHeaders['accept']).to.be.equal(
164+
'application/json');
165+
server.respond();
166+
167+
return request.completes.then(function() {
168+
expect(request.response).to.deep.eq({success: true});
169+
});
170+
});
171+
172+
test('json prefix is correctly stripped from the response with header', function () {
173+
var options = {
174+
url: '/responds_to_get_with_prefixed_json_and_header',
175+
handleAs: 'json'
176+
};
177+
178+
request.send(options);
179+
expect(server.requests.length).to.be.equal(1);
180+
expect(server.requests[0].requestHeaders['accept']).to.be.equal(
181+
'application/json');
182+
server.respond();
183+
184+
return request.completes.then(function() {
185+
expect(request.response).to.deep.eq({success: true});
186+
});
187+
});
188+
139189
test('responseType cannot be configured via handleAs option, when async is false', function () {
140190
var options = Object.create(successfulRequestOptions);
141191
options.handleAs = 'json';

0 commit comments

Comments
 (0)