Skip to content

Commit 15fcc57

Browse files
committed
feat: add more control through configuration @ ParseJSONResultsPlugin. (#1453)
1 parent 2036b32 commit 15fcc57

File tree

4 files changed

+196
-29
lines changed

4 files changed

+196
-29
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"@types/better-sqlite3": "^7.6.13",
115115
"@types/chai": "^5.2.3",
116116
"@types/chai-as-promised": "^8.0.2",
117+
"@types/prototype-pollution-vulnerable-lodash.merge-dont-upgrade": "npm:@types/lodash.merge@4.6.1",
117118
"@types/mocha": "^10.0.10",
118119
"@types/node": "^24.10.1",
119120
"@types/pg": "^8.15.6",
@@ -129,6 +130,7 @@
129130
"mocha": "^11.7.5",
130131
"mysql2": "^3.15.3",
131132
"pathe": "^2.0.3",
133+
"prototype-pollution-vulnerable-lodash.merge-dont-upgrade": "npm:lodash.merge@4.6.1",
132134
"pg": "^8.15.6",
133135
"pg-cursor": "^2.14.6",
134136
"pkg-types": "^2.3.0",

pnpm-lock.yaml

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/plugin/parse-json-results/parse-json-results-plugin.ts

Lines changed: 126 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { QueryResult } from '../../driver/database-connection.js'
22
import 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'
44
import type { UnknownRow } from '../../util/type-utils.js'
55
import type {
66
KyselyPlugin,
@@ -9,6 +9,17 @@ import type {
99
} from '../kysely-plugin.js'
1010

1111
export 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

2542
type 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
*/
7194
export 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

131206
function maybeJson(value: string): boolean {
132-
return value.match(/^[\[\{]/) != null
207+
return (
208+
(value.startsWith('{') && value.endsWith('}')) ||
209+
(value.startsWith('[') && value.endsWith(']'))
210+
)
133211
}
134212

135213
function 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

test/node/src/parse-json-results-plugin.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ParseJSONResultsPlugin } from '../../..'
22
import { createQueryId } from '../../../dist/cjs/util/query-id.js'
3+
import { expect } from './test-setup'
4+
import merge from 'prototype-pollution-vulnerable-lodash.merge-dont-upgrade'
35

46
describe('ParseJSONResultsPlugin', () => {
57
describe("when `objectStrategy` is 'create'", () => {
@@ -24,4 +26,47 @@ describe('ParseJSONResultsPlugin', () => {
2426
})
2527
})
2628
})
29+
30+
it('should omit dangerious keys when JSON parsing, denying prototype pollution downstream', async () => {
31+
const plugin = new ParseJSONResultsPlugin({ objectStrategy: 'create' })
32+
33+
const maliciousRow = {
34+
id: 1,
35+
__proto__: JSON.stringify({ isAdmin: true }),
36+
joe: JSON.stringify({
37+
age: 30,
38+
__proto__: { isAdmin: true },
39+
constructor: JSON.stringify({
40+
prototype: { isAdmin: true },
41+
true: false,
42+
}),
43+
joe: JSON.stringify({
44+
__proto__: { isAdmin: true },
45+
true: false,
46+
}),
47+
}),
48+
constructor: JSON.stringify({
49+
prototype: { isAdmin: true },
50+
true: false,
51+
__proto__: { isAdmin: true },
52+
}),
53+
prototype: JSON.stringify({
54+
isAdmin: true,
55+
__proto__: { isAdmin: true },
56+
}),
57+
}
58+
59+
const {
60+
rows: [rowParsedByPlugin],
61+
} = await plugin.transformResult({
62+
queryId: createQueryId(),
63+
result: {
64+
rows: [maliciousRow],
65+
},
66+
})
67+
68+
const mergedWithRowParsedByPlugin = merge({}, rowParsedByPlugin)
69+
70+
expect((mergedWithRowParsedByPlugin as any).isAdmin).to.be.undefined
71+
})
2772
})

0 commit comments

Comments
 (0)