11import type { QueryResult } from '../../driver/database-connection.js'
22import type { RootOperationNode } from '../../query-compiler/query-compiler.js'
3- import { isPlainObject , isString } from '../../util/object-utils.js'
3+ import { freeze , isPlainObject , isString } from '../../util/object-utils.js'
44import type { UnknownRow } from '../../util/type-utils.js'
55import type {
66 KyselyPlugin ,
@@ -9,6 +9,17 @@ import type {
99} from '../kysely-plugin.js'
1010
1111export interface ParseJSONResultsPluginOptions {
12+ /**
13+ * A function that returns `true` if the given string is a JSON string that should be parsed. If a detected JSON string fails to parse, an error is thrown.
14+ *
15+ * Defaults to a function that checks if the string starts and ends with `{}` or `[]` - meaning anything that might be a JSON string, is attempted to be parsed - and if fails, proceeds.
16+ *
17+ * @param value - The string value to check.
18+ * @param path - The JSON path leading to this value. e.g. `$[0].users[0].profile`
19+ * @return `true` if the string should be JSON parsed.
20+ */
21+ shouldParse ?: ( value : string , path : string ) => boolean
22+
1223 /**
1324 * When `'in-place'`, arrays' and objects' values are parsed in-place. This is
1425 * the most time and space efficient option.
@@ -20,10 +31,22 @@ export interface ParseJSONResultsPluginOptions {
2031 * Defaults to `'in-place'`.
2132 */
2233 objectStrategy ?: ObjectStrategy
34+
35+ /**
36+ * The reviver function that will be passed to `JSON.parse`.
37+ * See {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#the_reviver_parameter | The reviver parameter}.
38+ */
39+ reviver ?: ( key : string , value : unknown , context ?: any ) => unknown
2340}
2441
2542type ObjectStrategy = 'in-place' | 'create'
2643
44+ type ProcessedParseJSONResultsPluginOptions = {
45+ readonly [ K in keyof ParseJSONResultsPluginOptions ] -?: K extends 'skipKeys'
46+ ? Record < string , true >
47+ : ParseJSONResultsPluginOptions [ K ]
48+ }
49+
2750/**
2851 * Parses JSON strings in query results into JSON objects.
2952 *
@@ -69,10 +92,19 @@ type ObjectStrategy = 'in-place' | 'create'
6992 * ```
7093 */
7194export class ParseJSONResultsPlugin implements KyselyPlugin {
72- readonly #objectStrategy: ObjectStrategy
73-
74- constructor ( readonly opt : ParseJSONResultsPluginOptions = { } ) {
75- this . #objectStrategy = opt . objectStrategy || 'in-place'
95+ readonly #options: ProcessedParseJSONResultsPluginOptions
96+
97+ constructor ( readonly options : ParseJSONResultsPluginOptions = { } ) {
98+ const { shouldParse } = options
99+
100+ this . #options = freeze ( {
101+ objectStrategy : options . objectStrategy || 'in-place' ,
102+ reviver : options . reviver || ( ( _ , value ) => value ) ,
103+ shouldParse : shouldParse
104+ ? ( value : string , path : string ) =>
105+ maybeJson ( value ) && shouldParse ( value , path )
106+ : maybeJson ,
107+ } )
76108 }
77109
78110 // noop
@@ -85,61 +117,126 @@ export class ParseJSONResultsPlugin implements KyselyPlugin {
85117 ) : Promise < QueryResult < UnknownRow > > {
86118 return {
87119 ...args . result ,
88- rows : parseArray ( args . result . rows , this . #objectStrategy ) ,
120+ rows : parseArray ( args . result . rows , '$' , this . #options ) ,
89121 }
90122 }
91123}
92124
93- function parseArray < T > ( arr : T [ ] , objectStrategy : ObjectStrategy ) : T [ ] {
94- const target = objectStrategy === 'create' ? new Array ( arr . length ) : arr
125+ function parseArray < T > (
126+ arr : T [ ] ,
127+ path : string ,
128+ options : ProcessedParseJSONResultsPluginOptions ,
129+ ) : T [ ] {
130+ const target =
131+ options . objectStrategy === 'create' ? new Array ( arr . length ) : arr
95132
96133 for ( let i = 0 ; i < arr . length ; ++ i ) {
97- target [ i ] = parse ( arr [ i ] , objectStrategy ) as T
134+ target [ i ] = parse ( arr [ i ] , ` ${ path } [ ${ i } ]` , options ) as T
98135 }
99136
100137 return target
101138}
102139
103- function parse ( obj : unknown , objectStrategy : ObjectStrategy ) : unknown {
104- if ( isString ( obj ) ) {
105- return parseString ( obj )
140+ function parse (
141+ value : unknown ,
142+ path : string ,
143+ options : ProcessedParseJSONResultsPluginOptions ,
144+ ) : unknown {
145+ if ( isString ( value ) ) {
146+ return parseString ( value , path , options )
106147 }
107148
108- if ( Array . isArray ( obj ) ) {
109- return parseArray ( obj , objectStrategy )
149+ if ( Array . isArray ( value ) ) {
150+ return parseArray ( value , path , options )
110151 }
111152
112- if ( isPlainObject ( obj ) ) {
113- return parseObject ( obj , objectStrategy )
153+ if ( isPlainObject ( value ) ) {
154+ return parseObject ( value , path , options )
114155 }
115156
116- return obj
157+ return value
117158}
118159
119- function parseString ( str : string ) : unknown {
120- if ( maybeJson ( str ) ) {
121- try {
122- return parse ( JSON . parse ( str ) , 'in-place' )
123- } catch ( err ) {
124- // this catch block is intentionally empty.
125- }
160+ function parseString (
161+ str : string ,
162+ path : string ,
163+ options : ProcessedParseJSONResultsPluginOptions ,
164+ ) : unknown {
165+ const { shouldParse } = options
166+
167+ if ( ! shouldParse ( str , path ) ) {
168+ return str
126169 }
127170
128- return str
171+ try {
172+ return parse (
173+ JSON . parse ( str , ( key , value , ...otherArgs ) => {
174+ // prevent prototype pollution
175+ if ( key === '__proto__' ) {
176+ return
177+ }
178+
179+ // prevent prototype pollution
180+ if (
181+ key === 'constructor' &&
182+ isPlainObject ( value ) &&
183+ Object . hasOwn ( value , 'prototype' )
184+ ) {
185+ delete value . prototype
186+ }
187+
188+ return options . reviver ( key , value , ...otherArgs )
189+ } ) ,
190+ path ,
191+ { ...options , objectStrategy : 'in-place' } ,
192+ )
193+ } catch ( error ) {
194+ // custom JSON detection should expose parsing errors.
195+ if ( shouldParse !== maybeJson ) {
196+ throw error
197+ }
198+
199+ // built-in naive heuristic should keep going despite errors given there might be false positives in detection.
200+ console . error ( error )
201+
202+ return str
203+ }
129204}
130205
131206function maybeJson ( value : string ) : boolean {
132- return value . match ( / ^ [ \[ \{ ] / ) != null
207+ return (
208+ ( value . startsWith ( '{' ) && value . endsWith ( '}' ) ) ||
209+ ( value . startsWith ( '[' ) && value . endsWith ( ']' ) )
210+ )
133211}
134212
135213function parseObject (
136214 obj : Record < string , unknown > ,
137- objectStrategy : ObjectStrategy ,
215+ path : string ,
216+ options : ProcessedParseJSONResultsPluginOptions ,
138217) : Record < string , unknown > {
218+ const { objectStrategy } = options
219+
139220 const target = objectStrategy === 'create' ? { } : obj
140221
141- for ( const key in obj ) {
142- target [ key ] = parse ( obj [ key ] , objectStrategy )
222+ for ( const key of Object . keys ( obj ) ) {
223+ // prevent prototype pollution
224+ if ( key === '__proto__' ) {
225+ continue
226+ }
227+
228+ const parsed = parse ( obj [ key ] , `${ path } .${ key } ` , options )
229+
230+ // prevent prototype pollution
231+ if (
232+ key === 'constructor' &&
233+ isPlainObject ( parsed ) &&
234+ Object . hasOwn ( parsed , 'prototype' )
235+ ) {
236+ delete parsed . prototype
237+ }
238+
239+ target [ key ] = parsed
143240 }
144241
145242 return target
0 commit comments