Skip to content
Open
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
57 changes: 47 additions & 10 deletions src/parsers/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ const strictJSONReg = /^[\x20\x09\x0a\x0d]*(\[|\{)/

/**
* Prepares parser options for JSON body parsing by configuring strict mode
* and value normalization through a reviver function.
* and value normalization.
*
* @param options - JSON body parser configuration
*/
export function prepareJSONParserOptions(options: Partial<BodyParserJSONConfig>): RawBodyOptions & {
encoding: Encoding
strict: boolean
reviver?: (this: any, key: string, value: any) => any
normalizer?: (value: string) => string | null
} {
let normalizer: undefined | ((value: string) => string | null)
if (options.convertEmptyStringsToNull && options.trimWhitespaces) {
Expand All @@ -46,14 +46,46 @@ export function prepareJSONParserOptions(options: Partial<BodyParserJSONConfig>)
return {
...prepareTextParserOptions(options),
strict: options.strict !== false,
reviver: normalizer
? function JSONReviver(key, value) {
if (key === '') {
return value
}
return typeof value === 'string' ? normalizer(value) : value
normalizer,
}
}

/**
* Normalizes string values without using a JSON reviver, which is considerably
* slower than parsing first. The explicit stack also supports deeply nested JSON.
*/
function normalizeJSONValues(value: unknown, normalizer: (value: string) => string | null) {
if (!value || typeof value !== 'object') {
return
}

const stack: (Record<string, unknown> | unknown[])[] = [
value as Record<string, unknown> | unknown[],
]
while (stack.length) {
const current = stack.pop()!

if (Array.isArray(current)) {
for (let index = 0; index < current.length; index++) {
const child = current[index]
if (typeof child === 'string') {
current[index] = normalizer(child)
} else if (child && typeof child === 'object') {
stack.push(child as Record<string, unknown> | unknown[])
}
}
continue
}

for (const [key, child] of Object.entries(current)) {
if (typeof child === 'string') {
if (key !== '') {
current[key] = normalizer(child)
}
: undefined,
} else if (child && typeof child === 'object') {
stack.push(child as Record<string, unknown> | unknown[])
}
}
}
}

Expand Down Expand Up @@ -100,8 +132,13 @@ export async function parseJSON(
}

try {
const parsed = safeParse(requestBody)
if (options.normalizer) {
normalizeJSONValues(parsed, options.normalizer)
}

return {
parsed: safeParse(requestBody, options.reviver),
parsed,
raw: requestBody,
}
} catch (error: any) {
Expand Down
97 changes: 97 additions & 0 deletions tests/parsers/json.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,103 @@ test.group('JSON parser', () => {
})
})

test('do not normalize values assigned to empty keys', async ({ assert }) => {
const server = createServer(async (req, res) => {
const body = await parseJSON(
req,
prepareJSONParserOptions({
convertEmptyStringsToNull: true,
trimWhitespaces: true,
})
)
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
})

const payload = {
'': { value: ' value ', items: [''] },
'nested': { '': '', 'value': ' value ' },
'array': [{ '': ' ', 'value': '' }],
}
const { body } = await supertest(server).post('/').type('json').send(payload).expect(200)

assert.deepEqual(body, {
parsed: {
'': { value: 'value', items: [null] },
'nested': { '': '', 'value': 'value' },
'array': [{ '': ' ', 'value': null }],
},
raw: JSON.stringify(payload),
})
})

test('do not normalize a primitive root value in non-strict mode', async ({ assert }) => {
const server = createServer(async (req, res) => {
const body = await parseJSON(
req,
prepareJSONParserOptions({
strict: false,
convertEmptyStringsToNull: true,
trimWhitespaces: true,
})
)
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
})

const { body } = await supertest(server).post('/').type('json').send('" value "').expect(200)

assert.deepEqual(body, {
parsed: ' value ',
raw: '" value "',
})
})

test('remove prototype poisoning properties before normalizing values', async ({ assert }) => {
const server = createServer(async (req, res) => {
const body = await parseJSON(
req,
prepareJSONParserOptions({
convertEmptyStringsToNull: true,
trimWhitespaces: true,
})
)
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
})

const payload =
'{"value":" keep ","__proto__":{"polluted":"yes"},"nested":{"constructor":{"prototype":{"polluted":"yes"}}}}'
const { body } = await supertest(server).post('/').type('json').send(payload).expect(200)

assert.deepEqual(body, {
parsed: { value: 'keep', nested: {} },
raw: payload,
})
assert.isFalse('polluted' in Object.prototype)
})

test('normalize deeply nested values without recursion', async ({ assert }) => {
const depth = 10_000
const payload = `${'['.repeat(depth)}" value "${']'.repeat(depth)}`
const server = createServer(async (req, res) => {
const body = await parseJSON(
req,
prepareJSONParserOptions({
trimWhitespaces: true,
})
)
let value = body.parsed
for (let index = 0; index < depth; index++) {
value = value[0]
}
res.end(value)
})

const { text } = await supertest(server).post('/').type('json').send(payload).expect(200)
assert.equal(text, 'value')
})

test('trim whitespaces and convert empty string to null', async ({ assert }) => {
const server = createServer(async (req, res) => {
try {
Expand Down
Loading