Skip to content

Commit 9ce1092

Browse files
authored
Merge pull request #402 from bigcommerce/trac-8385
fix: TRAC-838 resolve unsafe JSONStringify helper
2 parents 0f9f894 + b483b33 commit 9ce1092

5 files changed

Lines changed: 60 additions & 6 deletions

File tree

helpers/3p/object.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
var hasOwn = Object.hasOwnProperty;
44
var utils = require('./utils/');
5+
var common = require('../lib/common.js');
56

67
/**
78
* Expose `helpers`
@@ -209,7 +210,9 @@ helpers.JSONstringify = function(obj, indent) {
209210
if (!utils.isNumber(indent)) {
210211
indent = 0;
211212
}
212-
return JSON.stringify(obj, null, indent);
213+
// Escape characters that are unsafe when this output is embedded inside an
214+
// HTML <script> tag (e.g. JSON-LD), preventing reflected XSS via `</script>`.
215+
return common.escapeJsonForHtml(JSON.stringify(obj, null, indent));
213216
};
214217

215218
/**

helpers/json.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const common = require('./lib/common.js');
44
const factory = globals => {
55
return function(data) {
66
data = common.unwrapIfSafeString(globals.handlebars, data);
7-
return JSON.stringify(data);
7+
return common.escapeJsonForHtml(JSON.stringify(data));
88
};
99
};
1010

helpers/lib/common.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ function isValidURL(val) {
1111

1212
/*
1313
* Based on https://github.com/jonschlinkert/get-value/blob/2.0.6/index.js with some enhancements.
14-
*
14+
*
1515
* - Performs "hasOwnProperty" checks for safety.
1616
* - Now accepts Handlebars.SafeString paths.
1717
*/
@@ -67,6 +67,38 @@ function unwrapIfSafeString(handlebars, val) {
6767
return val;
6868
}
6969

70+
// Maps the char code of characters that are valid inside a JSON string but
71+
// dangerous when that JSON is emitted (unescaped) inside an HTML <script> tag,
72+
// to their \uXXXX escape sequences. Encoding `<` and `/` prevents the HTML
73+
// parser from ever seeing a closing tag such as `</script>`; `>` guards
74+
// against `-->`/`]]>`; U+2028/U+2029 are illegal in JS string literals. Every
75+
// replacement is still valid JSON and round-trips through `JSON.parse`.
76+
const HTML_UNSAFE_JSON_ESCAPES = {
77+
0x3c: '\\u003c', // <
78+
0x3e: '\\u003e', // >
79+
0x2f: '\\u002f', // /
80+
0x2028: '\\u2028',
81+
0x2029: '\\u2029',
82+
};
83+
84+
const HTML_UNSAFE_JSON_REGEX = /[<>/\u2028\u2029]/g;
85+
86+
/**
87+
* Escape the output of `JSON.stringify` so it can be safely embedded inside an
88+
* HTML <script> tag. Encodes `<`, `>`, `/` and the U+2028/U+2029 line
89+
* separators as unicode escape sequences. The result is still valid JSON and
90+
* round-trips through `JSON.parse`.
91+
*
92+
* @param {string} jsonString - The output of JSON.stringify
93+
* @returns {string} - The HTML-safe JSON string
94+
*/
95+
function escapeJsonForHtml(jsonString) {
96+
if (typeof jsonString !== 'string') {
97+
return jsonString;
98+
}
99+
return jsonString.replace(HTML_UNSAFE_JSON_REGEX, char => HTML_UNSAFE_JSON_ESCAPES[char.charCodeAt(0)]);
100+
}
101+
70102
const maximumPixelSize = 5120;
71103

72104
/**
@@ -79,7 +111,7 @@ function appendLossyParam(url, lossy) {
79111
if (!lossy || typeof lossy !== 'boolean') {
80112
return url;
81113
}
82-
114+
83115
const urlObj = new URL(url);
84116
urlObj.searchParams.set('compression', 'lossy');
85117
return urlObj.toString();
@@ -89,6 +121,7 @@ module.exports = {
89121
isValidURL,
90122
getValue,
91123
unwrapIfSafeString,
124+
escapeJsonForHtml,
92125
maximumPixelSize,
93126
appendLossyParam
94127
};

spec/helpers/3p/object.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,5 +165,13 @@ describe('object', function() {
165165
expect(res).to.equal('{"name":"Halle","age":4,"userid":"Nicole"}');
166166
done();
167167
});
168+
169+
it('should escape HTML-unsafe characters so output is safe inside a <script> tag:', function(done) {
170+
var fn = hbs.compile('{{{JSONstringify data}}}');
171+
var res = fn({data: 'Music</script><script>alert(1)</script>'});
172+
expect(res).to.equal('"Music\\u003c\\u002fscript\\u003e\\u003cscript\\u003ealert(1)\\u003c\\u002fscript\\u003e"');
173+
expect(res).to.not.contain('</script>');
174+
done();
175+
});
168176
});
169177
});

spec/helpers/json.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ describe('json helper', function() {
1010
image_with_2_qs: {
1111
data: urlData_2_qs
1212
},
13-
object: { a: 1, b: "hello" }
13+
object: { a: 1, b: "hello" },
14+
xss: 'Music</script><script>alert(1)</script>'
1415
};
1516

1617
const runTestCases = testRunner({context});
@@ -27,7 +28,7 @@ describe('json helper', function() {
2728
runTestCases([
2829
{
2930
input: '{{{json (getImage image_with_2_qs)}}}',
30-
output: '"https://cdn.example.com/path/to/original/image.png?c=2&imbypass=on"',
31+
output: '"https:\\u002f\\u002fcdn.example.com\\u002fpath\\u002fto\\u002foriginal\\u002fimage.png?c=2&imbypass=on"',
3132
},
3233
], done);
3334
});
@@ -40,4 +41,13 @@ describe('json helper', function() {
4041
},
4142
], done);
4243
});
44+
45+
it('should escape HTML-unsafe characters so output is safe inside a <script> tag', function(done) {
46+
runTestCases([
47+
{
48+
input: '{{{json xss}}}',
49+
output: '"Music\\u003c\\u002fscript\\u003e\\u003cscript\\u003ealert(1)\\u003c\\u002fscript\\u003e"',
50+
},
51+
], done);
52+
});
4353
});

0 commit comments

Comments
 (0)