diff --git a/README.md b/README.md index 91d3d5a..342bbcd 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ Parses a given JSON-formatted text into an object where: - `'error'` - throw a `SyntaxError` when a `constructor.prototype` key is found. This is the default value. - `'remove'` - deletes any `constructor` keys from the result object. - `'ignore'` - skips all validation (same as calling `JSON.parse()` directly). + - `safe` - optional boolean: + - `true` - returns `null` instead of throwing when a forbidden prototype property is found. + - `false` - default behavior (throws or removes based on `protoAction`/`constructorAction`). ### `sjson.scan(obj, [options])` @@ -79,6 +82,9 @@ Scans a given object for prototype properties where: - `constructorAction` - optional string with one of: - `'error'` - throw a `SyntaxError` when a `constructor.prototype` key is found. This is the default value. - `'remove'` - deletes any `constructor` keys from the input `obj`. + - `safe` - optional boolean: + - `true` - returns `null` instead of throwing when a forbidden prototype property is found. + - `false` - default behavior (throws or removes based on `protoAction`/`constructorAction`). ## Benchmarks diff --git a/index.js b/index.js index a46e37c..728ba96 100755 --- a/index.js +++ b/index.js @@ -4,6 +4,16 @@ const hasBuffer = typeof Buffer !== 'undefined' const suspectProtoRx = /"(?:_|\\u005[Ff])(?:_|\\u005[Ff])(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006[Ff])(?:t|\\u0074)(?:o|\\u006[Ff])(?:_|\\u005[Ff])(?:_|\\u005[Ff])"\s*:/ const suspectConstructorRx = /"(?:c|\\u0063)(?:o|\\u006[Ff])(?:n|\\u006[Ee])(?:s|\\u0073)(?:t|\\u0074)(?:r|\\u0072)(?:u|\\u0075)(?:c|\\u0063)(?:t|\\u0074)(?:o|\\u006[Ff])(?:r|\\u0072)"\s*:/ +/** + * @description Internal parse function that parses JSON text with security checks. + * @private + * @param {string|Buffer} text - The JSON text string or Buffer to parse. + * @param {Function} [reviver] - The JSON.parse() optional reviver argument. + * @param {import('./types').ParseOptions} [options] - Optional configuration object. + * @returns {*} The parsed object. + * @throws {SyntaxError} If a forbidden prototype property is found and `options.protoAction` or + * `options.constructorAction` is `'error'`. + */ function _parse (text, reviver, options) { // Normalize arguments if (options == null) { @@ -56,6 +66,14 @@ function _parse (text, reviver, options) { return filter(obj, { protoAction, constructorAction, safe: options && options.safe }) } +/** + * @description Scans and filters an object for forbidden prototype properties. + * @param {Object} obj - The object being scanned. + * @param {import('./types').ParseOptions} [options] - Optional configuration object. + * @returns {Object|null} The filtered object, or `null` if safe mode is enabled and issues are found. + * @throws {SyntaxError} If a forbidden prototype property is found and `options.protoAction` or + * `options.constructorAction` is `'error'`. + */ function filter (obj, { protoAction = 'error', constructorAction = 'error', safe } = {}) { let next = [obj] @@ -99,6 +117,15 @@ function filter (obj, { protoAction = 'error', constructorAction = 'error', safe return obj } +/** + * @description Parses a given JSON-formatted text into an object. + * @param {string|Buffer} text - The JSON text string or Buffer to parse. + * @param {Function} [reviver] - The `JSON.parse()` optional reviver argument, or options object. + * @param {import('./types').ParseOptions} [options] - Optional configuration object. + * @returns {*} The parsed object. + * @throws {SyntaxError} If the JSON text is malformed or contains forbidden prototype properties + * when `options.protoAction` or `options.constructorAction` is `'error'`. + */ function parse (text, reviver, options) { const { stackTraceLimit } = Error Error.stackTraceLimit = 0 @@ -109,6 +136,12 @@ function parse (text, reviver, options) { } } +/** + * @description Safely parses a given JSON-formatted text into an object. + * @param {string|Buffer} text - The JSON text string or Buffer to parse. + * @param {Function} [reviver] - The `JSON.parse()` optional reviver argument. + * @returns {*|null|undefined} The parsed object, `null` if security issues found, or `undefined` on parse error. + */ function safeParse (text, reviver) { const { stackTraceLimit } = Error Error.stackTraceLimit = 0 diff --git a/test/index.test.js b/test/index.test.js index ef61d97..835d582 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -524,3 +524,126 @@ test('scan handles optional options', t => { t.doesNotThrow(() => j.scan({ a: 'b' })) t.end() }) + +test('safe option', t => { + t.test('parse with safe=true returns null on __proto__', t => { + const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }' + t.strictEqual(j.parse(text, { safe: true }), null) + t.end() + }) + + t.test('parse with safe=true returns null on constructor', t => { + const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }' + t.strictEqual(j.parse(text, { safe: true }), null) + t.end() + }) + + t.test('parse with safe=true returns object when valid', t => { + const text = '{ "a": 5, "b": 6 }' + t.deepEqual(j.parse(text, { safe: true }), { a: 5, b: 6 }) + t.end() + }) + + t.test('parse with safe=true and reviver', t => { + const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }' + const reviver = (_key, value) => { + return typeof value === 'number' ? value + 1 : value + } + t.strictEqual(j.parse(text, reviver, { safe: true }), null) + t.end() + }) + + t.test('parse with safe=true and protoAction=remove returns null', t => { + const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }' + t.strictEqual(j.parse(text, { safe: true, protoAction: 'remove' }), null) + t.end() + }) + + t.test('parse with safe=true and constructorAction=remove returns null', t => { + const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }' + t.strictEqual(j.parse(text, { safe: true, constructorAction: 'remove' }), null) + t.end() + }) + + t.test('parse with safe=false throws on __proto__', t => { + const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }' + t.throws(() => j.parse(text, { safe: false }), SyntaxError) + t.end() + }) + + t.test('parse with safe=false throws on constructor', t => { + const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }' + t.throws(() => j.parse(text, { safe: false }), SyntaxError) + t.end() + }) + + t.test('scan with safe=true returns null on __proto__', t => { + const obj = JSON.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }') + t.strictEqual(j.scan(obj, { safe: true }), null) + t.end() + }) + + t.test('scan with safe=true returns null on constructor', t => { + const obj = JSON.parse('{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }') + t.strictEqual(j.scan(obj, { safe: true }), null) + t.end() + }) + + t.test('scan with safe=true returns object when valid', t => { + const obj = { a: 5, b: 6 } + t.deepEqual(j.scan(obj, { safe: true }), { a: 5, b: 6 }) + t.end() + }) + + t.test('scan with safe=false throws on __proto__', t => { + const obj = JSON.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }') + t.throws(() => j.scan(obj, { safe: false }), SyntaxError) + t.end() + }) + + t.test('scan with safe=false throws on constructor', t => { + const obj = JSON.parse('{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }') + t.throws(() => j.scan(obj, { safe: false }), SyntaxError) + t.end() + }) + + t.test('parse with safe=true returns null on nested __proto__', t => { + const text = '{ "a": 5, "c": { "d": 0, "__proto__": { "y": 8 } } }' + t.strictEqual(j.parse(text, { safe: true }), null) + t.end() + }) + + t.test('parse with safe=true returns null on nested constructor', t => { + const text = '{ "a": 5, "c": { "d": 0, "constructor": {"prototype": {"bar": "baz"}} } }' + t.strictEqual(j.parse(text, { safe: true }), null) + t.end() + }) + + t.test('parse with safe=true and protoAction=ignore returns object', t => { + const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }' + t.deepEqual( + j.parse(text, { safe: true, protoAction: 'ignore' }), + JSON.parse(text) + ) + t.end() + }) + + t.test('parse with safe=true and constructorAction=ignore returns object', t => { + const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }' + t.deepEqual( + j.parse(text, { safe: true, constructorAction: 'ignore' }), + JSON.parse(text) + ) + t.end() + }) + + t.test('should reset stackTraceLimit with safe option', t => { + const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }' + Error.stackTraceLimit = 42 + t.strictEqual(j.parse(text, { safe: true }), null) + t.same(Error.stackTraceLimit, 42) + t.end() + }) + + t.end() +}) diff --git a/types/index.d.ts b/types/index.d.ts index fe38cc3..4ccce04 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -16,6 +16,8 @@ declare namespace parse { * - `'ignore'` - skips all validation (same as calling `JSON.parse()` directly). */ constructorAction?: 'error' | 'remove' | 'ignore'; + /** If `true`, returns `null` instead of throwing an error when a security issue is found. */ + safe?: boolean; } export type ScanOptions = ParseOptions @@ -33,7 +35,7 @@ declare namespace parse { export const parse: Parse /** - * Parses a given JSON-formatted text into an object. + * Safely parses a given JSON-formatted text into an object. * * @param text The JSON text string. * @param reviver The `JSON.parse()` optional `reviver` argument. diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 338bf2f..c12e348 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -12,6 +12,11 @@ expectError(sjson.parse('"test"', { constructorAction: 'incorrect' })) sjson.parse('test', { constructorAction: 'remove' }) sjson.parse('test', { protoAction: 'ignore' }) sjson.parse('test', () => {}, { protoAction: 'ignore', constructorAction: 'remove' }) +sjson.parse('"test"', null, { safe: true }) +sjson.parse('"test"', { safe: true }) +sjson.parse('test', () => {}, { safe: false }) +sjson.parse('test', { protoAction: 'remove', safe: true }) +expectError(sjson.parse('"test"', null, { safe: 'incorrect' })) sjson.safeParse('"test"', null) sjson.safeParse('"test"') @@ -22,6 +27,9 @@ sjson.scan({}, { protoAction: 'ignore' }) sjson.scan({}, { constructorAction: 'error' }) sjson.scan({}, { constructorAction: 'ignore' }) sjson.scan([], {}) +sjson.scan({}, { safe: true }) +sjson.scan({}, { protoAction: 'remove', safe: false }) +expectError(sjson.scan({}, { safe: 'incorrect' })) declare const input: Buffer sjson.parse(input)