Skip to content

Commit f21a6fb

Browse files
SiebesJordan Milneearyans
authored
Don't replace regex / function placeholders within string literals (#79)
Previously we weren't checking if the quote that started the placeholder was escaped or not, meaning an object like {"foo": /1"/, "bar": "a\"@__R-<UID>-0__@"} Would be serialized as {"foo": /1"/, "bar": "a\/1"/} meaning an attacker could escape out of `bar` if they controlled both `foo` and `bar` and were able to guess the value of `<UID>`. UID was generated once on startup, was chosen using `Math.random()` and had a keyspace of roughly 4 billion, so within the realm of an online attack. Here's a simple example that will cause `console.log()` to be called when the `serialize()`d version is `eval()`d eval('('+ serialize({"foo": /1" + console.log(1)/i, "bar": '"@__R-<UID>-0__@'}) + ')'); Where `<UID>` is the guessed `UID`. This fixes the issue by ensuring that placeholders are not preceded by a backslash. We also switch to a higher entropy `UID` to prevent people from guessing it. Co-authored-by: Jordan Milne <[email protected]> Co-authored-by: Ryan Siebert <[email protected]>
1 parent 1ac487e commit f21a6fb

File tree

4 files changed

+61
-5
lines changed

4 files changed

+61
-5
lines changed

index.js

+22-3
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ See the accompanying LICENSE file for terms.
66

77
'use strict';
88

9+
var randomBytes = require('randombytes');
10+
911
// Generate an internal UID to make the regexp pattern harder to guess.
10-
var UID = Math.floor(Math.random() * 0x10000000000).toString(16);
11-
var PLACE_HOLDER_REGEXP = new RegExp('"@__(F|R|D|M|S|U|I)-' + UID + '-(\\d+)__@"', 'g');
12+
var UID_LENGTH = 16;
13+
var UID = generateUID();
14+
var PLACE_HOLDER_REGEXP = new RegExp('(\\\\)?"@__(F|R|D|M|S|U|I)-' + UID + '-(\\d+)__@"', 'g');
1215

1316
var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g;
1417
var IS_PURE_FUNCTION = /function.*?\(/;
@@ -31,6 +34,15 @@ function escapeUnsafeChars(unsafeChar) {
3134
return ESCAPED_CHARS[unsafeChar];
3235
}
3336

37+
function generateUID() {
38+
var bytes = randomBytes(UID_LENGTH);
39+
var result = '';
40+
for(var i=0; i<UID_LENGTH; ++i) {
41+
result += bytes[i].toString(16);
42+
}
43+
return result;
44+
}
45+
3446
function deleteFunctions(obj){
3547
var functionKeys = [];
3648
for (var key in obj) {
@@ -187,7 +199,14 @@ module.exports = function serialize(obj, options) {
187199
// Replaces all occurrences of function, regexp, date, map and set placeholders in the
188200
// JSON string with their string representations. If the original value can
189201
// not be found, then `undefined` is used.
190-
return str.replace(PLACE_HOLDER_REGEXP, function (match, type, valueIndex) {
202+
return str.replace(PLACE_HOLDER_REGEXP, function (match, backSlash, type, valueIndex) {
203+
// The placeholder may not be preceded by a backslash. This is to prevent
204+
// replacing things like `"a\"@__R-<UID>-0__@"` and thus outputting
205+
// invalid JS.
206+
if (backSlash) {
207+
return match;
208+
}
209+
191210
if (type === 'D') {
192211
return "new Date(\"" + dates[valueIndex].toISOString() + "\")";
193212
}

package-lock.json

+9-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,8 @@
2929
"chai": "^4.1.0",
3030
"mocha": "^7.0.0",
3131
"nyc": "^15.0.0"
32+
},
33+
"dependencies": {
34+
"randombytes": "^2.1.0"
3235
}
3336
}

test/unit/serialize.js

+27
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
/* global describe, it, beforeEach */
22
'use strict';
33

4+
// temporarily monkeypatch `crypto.randomBytes` so we'll have a
5+
// predictable UID for our tests
6+
var crypto = require('crypto');
7+
var oldRandom = crypto.randomBytes;
8+
crypto.randomBytes = function(len, cb) {
9+
var buf = Buffer.alloc(len);
10+
buf.fill(0x00);
11+
if (cb)
12+
cb(null, buf);
13+
return buf;
14+
};
15+
416
var serialize = require('../../'),
517
expect = require('chai').expect;
618

19+
crypto.randomBytes = oldRandom;
20+
721
describe('serialize( obj )', function () {
822
it('should be a function', function () {
923
expect(serialize).to.be.a('function');
@@ -493,4 +507,17 @@ describe('serialize( obj )', function () {
493507
expect(serialize([1], 2)).to.equal('[\n 1\n]');
494508
});
495509
});
510+
511+
describe('placeholders', function() {
512+
it('should not be replaced within string literals', function () {
513+
// Since we made the UID deterministic this should always be the placeholder
514+
var fakePlaceholder = '"@__R-0000000000000000-0__@';
515+
var serialized = serialize({bar: /1/i, foo: fakePlaceholder}, {uid: 'foo'});
516+
var obj = eval('(' + serialized + ')');
517+
expect(obj).to.be.a('Object');
518+
expect(obj.foo).to.be.a('String');
519+
expect(obj.foo).to.equal(fakePlaceholder);
520+
});
521+
});
522+
496523
});

0 commit comments

Comments
 (0)