Skip to content

Commit 50893ec

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

File tree

4 files changed

+171
-2
lines changed

4 files changed

+171
-2
lines changed

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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ sjson.parse('test', { constructorAction: 'remove' })
1313
sjson.parse('test', { protoAction: 'ignore' })
1414
sjson.parse('test', () => {}, { protoAction: 'ignore', constructorAction: 'remove' })
1515

16+
// Test safe option
17+
sjson.parse('"test"', null, { safe: true })
18+
sjson.parse('"test"', { safe: true })
19+
sjson.parse('test', () => {}, { safe: false })
20+
sjson.parse('test', { protoAction: 'remove', safe: true })
21+
expectError(sjson.parse('"test"', null, { safe: 'incorrect' }))
22+
1623
sjson.safeParse('"test"', null)
1724
sjson.safeParse('"test"')
1825
expectError(sjson.safeParse(null))
@@ -22,6 +29,10 @@ sjson.scan({}, { protoAction: 'ignore' })
2229
sjson.scan({}, { constructorAction: 'error' })
2330
sjson.scan({}, { constructorAction: 'ignore' })
2431
sjson.scan([], {})
32+
// Test safe option in scan
33+
sjson.scan({}, { safe: true })
34+
sjson.scan({}, { protoAction: 'remove', safe: false })
35+
expectError(sjson.scan({}, { safe: 'incorrect' }))
2536

2637
declare const input: Buffer
2738
sjson.parse(input)
@@ -32,4 +43,4 @@ sjson.parse('{"anything":0}', (key, value) => {
3243
})
3344
sjson.safeParse('{"anything":0}', (key, value) => {
3445
expectType<string>(key)
35-
})
46+
})

0 commit comments

Comments
 (0)