From f21a6fb3ace2353413761e79717b2d210ba6ccbd Mon Sep 17 00:00:00 2001 From: Ryan Siebert Date: Wed, 20 May 2020 23:52:22 +1000 Subject: [PATCH] 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--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 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--0__@'}) + ')'); Where `` 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 Co-authored-by: Ryan Siebert --- index.js | 25 ++++++++++++++++++++++--- package-lock.json | 11 +++++++++-- package.json | 3 +++ test/unit/serialize.js | 27 +++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 841bb7e..26163d8 100644 --- a/index.js +++ b/index.js @@ -6,9 +6,12 @@ See the accompanying LICENSE file for terms. 'use strict'; +var randomBytes = require('randombytes'); + // Generate an internal UID to make the regexp pattern harder to guess. -var UID = Math.floor(Math.random() * 0x10000000000).toString(16); -var PLACE_HOLDER_REGEXP = new RegExp('"@__(F|R|D|M|S|U|I)-' + UID + '-(\\d+)__@"', 'g'); +var UID_LENGTH = 16; +var UID = generateUID(); +var PLACE_HOLDER_REGEXP = new RegExp('(\\\\)?"@__(F|R|D|M|S|U|I)-' + UID + '-(\\d+)__@"', 'g'); var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g; var IS_PURE_FUNCTION = /function.*?\(/; @@ -31,6 +34,15 @@ function escapeUnsafeChars(unsafeChar) { return ESCAPED_CHARS[unsafeChar]; } +function generateUID() { + var bytes = randomBytes(UID_LENGTH); + var result = ''; + for(var i=0; i-0__@"` and thus outputting + // invalid JS. + if (backSlash) { + return match; + } + if (type === 'D') { return "new Date(\"" + dates[valueIndex].toISOString() + "\")"; } diff --git a/package-lock.json b/package-lock.json index fbf7630..f101e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1592,6 +1592,14 @@ "fromentries": "^1.2.0" } }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "requires": { + "safe-buffer": "^5.1.0" + } + }, "readdirp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", @@ -1649,8 +1657,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "semver": { "version": "5.7.1", diff --git a/package.json b/package.json index 36073d1..12b3954 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ "chai": "^4.1.0", "mocha": "^7.0.0", "nyc": "^15.0.0" + }, + "dependencies": { + "randombytes": "^2.1.0" } } diff --git a/test/unit/serialize.js b/test/unit/serialize.js index 3944344..88b3603 100644 --- a/test/unit/serialize.js +++ b/test/unit/serialize.js @@ -1,9 +1,23 @@ /* global describe, it, beforeEach */ 'use strict'; +// temporarily monkeypatch `crypto.randomBytes` so we'll have a +// predictable UID for our tests +var crypto = require('crypto'); +var oldRandom = crypto.randomBytes; +crypto.randomBytes = function(len, cb) { + var buf = Buffer.alloc(len); + buf.fill(0x00); + if (cb) + cb(null, buf); + return buf; +}; + var serialize = require('../../'), expect = require('chai').expect; +crypto.randomBytes = oldRandom; + describe('serialize( obj )', function () { it('should be a function', function () { expect(serialize).to.be.a('function'); @@ -493,4 +507,17 @@ describe('serialize( obj )', function () { expect(serialize([1], 2)).to.equal('[\n 1\n]'); }); }); + + describe('placeholders', function() { + it('should not be replaced within string literals', function () { + // Since we made the UID deterministic this should always be the placeholder + var fakePlaceholder = '"@__R-0000000000000000-0__@'; + var serialized = serialize({bar: /1/i, foo: fakePlaceholder}, {uid: 'foo'}); + var obj = eval('(' + serialized + ')'); + expect(obj).to.be.a('Object'); + expect(obj.foo).to.be.a('String'); + expect(obj.foo).to.equal(fakePlaceholder); + }); + }); + });