Skip to content

Commit cba7d0a

Browse files
committed
types(index): add missing safe option
1 parent abf215a commit cba7d0a

File tree

5 files changed

+173
-1
lines changed

5 files changed

+173
-1
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ Parses a given JSON-formatted text into an object where:
6767
- `'error'` - throw a `SyntaxError` when a `constructor.prototype` key is found. This is the default value.
6868
- `'remove'` - deletes any `constructor` keys from the result object.
6969
- `'ignore'` - skips all validation (same as calling `JSON.parse()` directly).
70+
- `safe` - optional boolean:
71+
- `true` - returns `null` instead of throwing when a forbidden prototype property is found.
72+
- `false` - default behavior (throws or removes based on `protoAction`/`constructorAction`).
7073

7174
### `sjson.scan(obj, [options])`
7275

@@ -79,6 +82,9 @@ Scans a given object for prototype properties where:
7982
- `constructorAction` - optional string with one of:
8083
- `'error'` - throw a `SyntaxError` when a `constructor.prototype` key is found. This is the default value.
8184
- `'remove'` - deletes any `constructor` keys from the input `obj`.
85+
- `safe` - optional boolean:
86+
- `true` - returns `null` instead of throwing when a forbidden prototype property is found.
87+
- `false` - default behavior (throws or removes based on `protoAction`/`constructorAction`).
8288

8389
## Benchmarks
8490

index.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ const hasBuffer = typeof Buffer !== 'undefined'
44
const suspectProtoRx = /"(?:_|\\u005[Ff])(?:_|\\u005[Ff])(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006[Ff])(?:t|\\u0074)(?:o|\\u006[Ff])(?:_|\\u005[Ff])(?:_|\\u005[Ff])"\s*:/
55
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*:/
66

7+
/**
8+
* @description Internal parse function that parses JSON text with security checks.
9+
* @private
10+
* @param {string|Buffer} text - The JSON text string or Buffer to parse.
11+
* @param {Function} [reviver] - The JSON.parse() optional reviver argument.
12+
* @param {import('./types').ParseOptions} [options] - Optional configuration object.
13+
* @returns {*} The parsed object.
14+
* @throws {SyntaxError} If a forbidden prototype property is found and `options.protoAction` or
15+
* `options.constructorAction` is `'error'`.
16+
*/
717
function _parse (text, reviver, options) {
818
// Normalize arguments
919
if (options == null) {
@@ -56,6 +66,14 @@ function _parse (text, reviver, options) {
5666
return filter(obj, { protoAction, constructorAction, safe: options && options.safe })
5767
}
5868

69+
/**
70+
* @description Scans and filters an object for forbidden prototype properties.
71+
* @param {Object} obj - The object being scanned.
72+
* @param {import('./types').ParseOptions} [options] - Optional configuration object.
73+
* @returns {Object|null} The filtered object, or `null` if safe mode is enabled and issues are found.
74+
* @throws {SyntaxError} If a forbidden prototype property is found and `options.protoAction` or
75+
* `options.constructorAction` is `'error'`.
76+
*/
5977
function filter (obj, { protoAction = 'error', constructorAction = 'error', safe } = {}) {
6078
let next = [obj]
6179

@@ -99,6 +117,15 @@ function filter (obj, { protoAction = 'error', constructorAction = 'error', safe
99117
return obj
100118
}
101119

120+
/**
121+
* @description Parses a given JSON-formatted text into an object.
122+
* @param {string|Buffer} text - The JSON text string or Buffer to parse.
123+
* @param {Function} [reviver] - The `JSON.parse()` optional reviver argument, or options object.
124+
* @param {import('./types').ParseOptions} [options] - Optional configuration object.
125+
* @returns {*} The parsed object.
126+
* @throws {SyntaxError} If the JSON text is malformed or contains forbidden prototype properties
127+
* when `options.protoAction` or `options.constructorAction` is `'error'`.
128+
*/
102129
function parse (text, reviver, options) {
103130
const { stackTraceLimit } = Error
104131
Error.stackTraceLimit = 0
@@ -109,6 +136,12 @@ function parse (text, reviver, options) {
109136
}
110137
}
111138

139+
/**
140+
* @description Safely parses a given JSON-formatted text into an object.
141+
* @param {string|Buffer} text - The JSON text string or Buffer to parse.
142+
* @param {Function} [reviver] - The `JSON.parse()` optional reviver argument.
143+
* @returns {*|null|undefined} The parsed object, `null` if security issues found, or `undefined` on parse error.
144+
*/
112145
function safeParse (text, reviver) {
113146
const { stackTraceLimit } = Error
114147
Error.stackTraceLimit = 0

test/index.test.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,3 +524,126 @@ test('scan handles optional options', t => {
524524
t.doesNotThrow(() => j.scan({ a: 'b' }))
525525
t.end()
526526
})
527+
528+
test('safe option', t => {
529+
t.test('parse with safe=true returns null on __proto__', t => {
530+
const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'
531+
t.strictEqual(j.parse(text, { safe: true }), null)
532+
t.end()
533+
})
534+
535+
t.test('parse with safe=true returns null on constructor', t => {
536+
const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }'
537+
t.strictEqual(j.parse(text, { safe: true }), null)
538+
t.end()
539+
})
540+
541+
t.test('parse with safe=true returns object when valid', t => {
542+
const text = '{ "a": 5, "b": 6 }'
543+
t.deepEqual(j.parse(text, { safe: true }), { a: 5, b: 6 })
544+
t.end()
545+
})
546+
547+
t.test('parse with safe=true and reviver', t => {
548+
const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'
549+
const reviver = (_key, value) => {
550+
return typeof value === 'number' ? value + 1 : value
551+
}
552+
t.strictEqual(j.parse(text, reviver, { safe: true }), null)
553+
t.end()
554+
})
555+
556+
t.test('parse with safe=true and protoAction=remove returns null', t => {
557+
const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'
558+
t.strictEqual(j.parse(text, { safe: true, protoAction: 'remove' }), null)
559+
t.end()
560+
})
561+
562+
t.test('parse with safe=true and constructorAction=remove returns null', t => {
563+
const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }'
564+
t.strictEqual(j.parse(text, { safe: true, constructorAction: 'remove' }), null)
565+
t.end()
566+
})
567+
568+
t.test('parse with safe=false throws on __proto__', t => {
569+
const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'
570+
t.throws(() => j.parse(text, { safe: false }), SyntaxError)
571+
t.end()
572+
})
573+
574+
t.test('parse with safe=false throws on constructor', t => {
575+
const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }'
576+
t.throws(() => j.parse(text, { safe: false }), SyntaxError)
577+
t.end()
578+
})
579+
580+
t.test('scan with safe=true returns null on __proto__', t => {
581+
const obj = JSON.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }')
582+
t.strictEqual(j.scan(obj, { safe: true }), null)
583+
t.end()
584+
})
585+
586+
t.test('scan with safe=true returns null on constructor', t => {
587+
const obj = JSON.parse('{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }')
588+
t.strictEqual(j.scan(obj, { safe: true }), null)
589+
t.end()
590+
})
591+
592+
t.test('scan with safe=true returns object when valid', t => {
593+
const obj = { a: 5, b: 6 }
594+
t.deepEqual(j.scan(obj, { safe: true }), { a: 5, b: 6 })
595+
t.end()
596+
})
597+
598+
t.test('scan with safe=false throws on __proto__', t => {
599+
const obj = JSON.parse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }')
600+
t.throws(() => j.scan(obj, { safe: false }), SyntaxError)
601+
t.end()
602+
})
603+
604+
t.test('scan with safe=false throws on constructor', t => {
605+
const obj = JSON.parse('{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }')
606+
t.throws(() => j.scan(obj, { safe: false }), SyntaxError)
607+
t.end()
608+
})
609+
610+
t.test('parse with safe=true returns null on nested __proto__', t => {
611+
const text = '{ "a": 5, "c": { "d": 0, "__proto__": { "y": 8 } } }'
612+
t.strictEqual(j.parse(text, { safe: true }), null)
613+
t.end()
614+
})
615+
616+
t.test('parse with safe=true returns null on nested constructor', t => {
617+
const text = '{ "a": 5, "c": { "d": 0, "constructor": {"prototype": {"bar": "baz"}} } }'
618+
t.strictEqual(j.parse(text, { safe: true }), null)
619+
t.end()
620+
})
621+
622+
t.test('parse with safe=true and protoAction=ignore returns object', t => {
623+
const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'
624+
t.deepEqual(
625+
j.parse(text, { safe: true, protoAction: 'ignore' }),
626+
JSON.parse(text)
627+
)
628+
t.end()
629+
})
630+
631+
t.test('parse with safe=true and constructorAction=ignore returns object', t => {
632+
const text = '{ "a": 5, "b": 6, "constructor": {"prototype": {"bar": "baz"}} }'
633+
t.deepEqual(
634+
j.parse(text, { safe: true, constructorAction: 'ignore' }),
635+
JSON.parse(text)
636+
)
637+
t.end()
638+
})
639+
640+
t.test('should reset stackTraceLimit with safe option', t => {
641+
const text = '{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'
642+
Error.stackTraceLimit = 42
643+
t.strictEqual(j.parse(text, { safe: true }), null)
644+
t.same(Error.stackTraceLimit, 42)
645+
t.end()
646+
})
647+
648+
t.end()
649+
})

types/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ declare namespace parse {
1616
* - `'ignore'` - skips all validation (same as calling `JSON.parse()` directly).
1717
*/
1818
constructorAction?: 'error' | 'remove' | 'ignore';
19+
/** If `true`, returns `null` instead of throwing an error when a security issue is found. */
20+
safe?: boolean;
1921
}
2022

2123
export type ScanOptions = ParseOptions
@@ -33,7 +35,7 @@ declare namespace parse {
3335
export const parse: Parse
3436

3537
/**
36-
* Parses a given JSON-formatted text into an object.
38+
* Safely parses a given JSON-formatted text into an object.
3739
*
3840
* @param text The JSON text string.
3941
* @param reviver The `JSON.parse()` optional `reviver` argument.

types/index.test-d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ expectError(sjson.parse('"test"', { constructorAction: 'incorrect' }))
1212
sjson.parse('test', { constructorAction: 'remove' })
1313
sjson.parse('test', { protoAction: 'ignore' })
1414
sjson.parse('test', () => {}, { protoAction: 'ignore', constructorAction: 'remove' })
15+
sjson.parse('"test"', null, { safe: true })
16+
sjson.parse('"test"', { safe: true })
17+
sjson.parse('test', () => {}, { safe: false })
18+
sjson.parse('test', { protoAction: 'remove', safe: true })
19+
expectError(sjson.parse('"test"', null, { safe: 'incorrect' }))
1520

1621
sjson.safeParse('"test"', null)
1722
sjson.safeParse('"test"')
@@ -22,6 +27,9 @@ sjson.scan({}, { protoAction: 'ignore' })
2227
sjson.scan({}, { constructorAction: 'error' })
2328
sjson.scan({}, { constructorAction: 'ignore' })
2429
sjson.scan([], {})
30+
sjson.scan({}, { safe: true })
31+
sjson.scan({}, { protoAction: 'remove', safe: false })
32+
expectError(sjson.scan({}, { safe: 'incorrect' }))
2533

2634
declare const input: Buffer
2735
sjson.parse(input)

0 commit comments

Comments
 (0)