Skip to content

Commit 31ad2d3

Browse files
Jordan MilneJordanMilne
Jordan Milne
authored andcommitted
Don't replace regex / function placeholders within string literals
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.
1 parent adfee60 commit 31ad2d3

File tree

3 files changed

+50
-4
lines changed

3 files changed

+50
-4
lines changed

index.js

+21-4
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ See the accompanying LICENSE file for terms.
66

77
'use strict';
88

9-
var isRegExp = require('util').isRegExp;
9+
var isRegExp = require('util').isRegExp;
10+
var randomBytes = require('randombytes');
1011

1112
// Generate an internal UID to make the regexp pattern harder to guess.
12-
var UID = Math.floor(Math.random() * 0x10000000000).toString(16);
13-
var PLACE_HOLDER_REGEXP = new RegExp('"@__(F|R)-' + UID + '-(\\d+)__@"', 'g');
13+
var UID_LENGTH = 16;
14+
var UID = generateUID();
15+
var PLACE_HOLDER_REGEXP = new RegExp('(\\\\)?"@__(F|R)-' + UID + '-(\\d+)__@"', 'g');
1416

1517
var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g;
1618
var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g;
@@ -29,6 +31,15 @@ function escapeUnsafeChars(unsafeChar) {
2931
return ESCAPED_CHARS[unsafeChar];
3032
}
3133

34+
function generateUID() {
35+
var bytes = randomBytes(UID_LENGTH);
36+
var result = '';
37+
for(var i=0; i<UID_LENGTH; ++i) {
38+
result += bytes[i].toString(16);
39+
}
40+
return result;
41+
}
42+
3243
module.exports = function serialize(obj, options) {
3344
options || (options = {});
3445

@@ -92,7 +103,13 @@ module.exports = function serialize(obj, options) {
92103
// Replaces all occurrences of function and regexp placeholders in the JSON
93104
// string with their string representations. If the original value can not
94105
// be found, then `undefined` is used.
95-
return str.replace(PLACE_HOLDER_REGEXP, function (match, type, valueIndex) {
106+
return str.replace(PLACE_HOLDER_REGEXP, function (match, backSlash, type, valueIndex) {
107+
// The placeholder may not be preceded by a backslash. This is to prevent
108+
// replacing things like `"a\"@__R-<UID>-0__@"` and thus outputting
109+
// invalid JS.
110+
if (backSlash) {
111+
return match;
112+
}
96113
if (type === 'R') {
97114
return regexps[valueIndex].toString();
98115
}

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,8 @@
3030
"istanbul": "^0.3.2",
3131
"mocha": "^1.21.4",
3232
"xunit-file": "0.0.5"
33+
},
34+
"dependencies": {
35+
"randombytes": "^2.0.3"
3336
}
3437
}

test/unit/serialize.js

+26
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 = new Buffer(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');
@@ -111,6 +125,18 @@ describe('serialize( obj )', function () {
111125
});
112126
});
113127

128+
describe('placeholders', function() {
129+
it('should not be replaced within string literals', function () {
130+
// Since we made the UID deterministic this should always be the placeholder
131+
var fakePlaceholder = '"@__R-0000000000000000-0__@';
132+
var serialized = serialize({bar: /1/i, foo: fakePlaceholder}, {uid: 'foo'});
133+
var obj = eval('(' + serialized + ')');
134+
expect(obj).to.be.a('Object');
135+
expect(obj.foo).to.be.a('String');
136+
expect(obj.foo).to.equal(fakePlaceholder);
137+
});
138+
});
139+
114140
describe('regexps', function () {
115141
it('should serialize constructed regexps', function () {
116142
var re = new RegExp('asdf');

0 commit comments

Comments
 (0)