Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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])`

Expand All @@ -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

Expand Down
33 changes: 33 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
123 changes: 123 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
4 changes: 3 additions & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"')
Expand All @@ -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)
Expand Down