From 4ded26557b8b07bdcc91deba11c5be61cc23b70d Mon Sep 17 00:00:00 2001 From: Romain Lanz Date: Tue, 1 Sep 2026 19:26:37 +0000 Subject: [PATCH] perf: speed up JSON value normalization --- src/parsers/json.ts | 57 ++++++++++++++++++---- tests/parsers/json.spec.ts | 97 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/parsers/json.ts b/src/parsers/json.ts index d49c1fe..dce7d6a 100644 --- a/src/parsers/json.ts +++ b/src/parsers/json.ts @@ -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): 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) { @@ -46,14 +46,46 @@ export function prepareJSONParserOptions(options: Partial) 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 | unknown[])[] = [ + value as Record | 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 | 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 | unknown[]) + } + } } } @@ -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) { diff --git a/tests/parsers/json.spec.ts b/tests/parsers/json.spec.ts index 9fba4c7..2ea6100 100644 --- a/tests/parsers/json.spec.ts +++ b/tests/parsers/json.spec.ts @@ -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 {