From 1c955ed1c546bbfd06ee49ecd367607699fff0c0 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 1 Aug 2026 01:26:16 +0000 Subject: [PATCH] fix(proto-parser): guard the inlined nested-obj helper against prototype pollution --- .../astHelpers/inlineNestedObj/path-obj.ts | 87 ++++++++++-------- eslint.config.mjs | 8 -- .../__snapshots__/utils.test.ts.snap | 87 ++++++++++-------- .../__tests__/inline-helpers.test.ts | 16 ++++ .../src/inline-helpers/nested-obj.ts | 92 +++++++++++-------- 5 files changed, 167 insertions(+), 123 deletions(-) create mode 100644 packages/proto-parser/__tests__/inline-helpers.test.ts diff --git a/__fixtures__/output/utils/astHelpers/inlineNestedObj/path-obj.ts b/__fixtures__/output/utils/astHelpers/inlineNestedObj/path-obj.ts index bc3f0528..b9c31d7a 100644 --- a/__fixtures__/output/utils/astHelpers/inlineNestedObj/path-obj.ts +++ b/__fixtures__/output/utils/astHelpers/inlineNestedObj/path-obj.ts @@ -4,46 +4,57 @@ * and run the pg-proto-parser generate command to regenerate this file. */ +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function parsePath(path: string): string[] { + const keys = path.replace(/\[(\w+)\]/g, '.$1').split('.'); + for (const key of keys) { + if (UNSAFE_KEYS.has(key)) { + throw new Error('Unsafe path segment: ' + key); + } + } + return keys; +} + export default { - get(obj: Record, path: string): T | undefined { - const keys = path.replace(/[(w+)]/g, '.$1').split('.'); - let result: any = obj; - for (const key of keys) { - if (result == null) { - return undefined; - } - result = result[key]; + get(obj: Record, path: string): T | undefined { + const keys = parsePath(path); + let result: any = obj; + for (const key of keys) { + if (result == null) { + return undefined; } - return result as T; - }, - - set(obj: Record, path: string, value: any): void { - if (value === undefined) { - return; - } - - const keys = path.replace(/[(w+)]/g, '.$1').split('.'); - let current = obj; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (typeof current[key] !== 'object') { - current[key] = {}; - } - current = current[key]; + result = result[key]; + } + return result as T; + }, + + set(obj: Record, path: string, value: any): void { + if (value === undefined) { + return; + } + + const keys = parsePath(path); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (typeof current[key] !== 'object') { + current[key] = {}; } - current[keys[keys.length - 1]] = value; - }, - - has(obj: Record, path: string): boolean { - const keys = path.replace(/[(w+)]/g, '.$1').split('.'); - let current = obj; - for (const key of keys) { - if (current == null || !(key in current)) { - return false; - } - current = current[key]; + current = current[key]; + } + current[keys[keys.length - 1]] = value; + }, + + has(obj: Record, path: string): boolean { + const keys = parsePath(path); + let current = obj; + for (const key of keys) { + if (current == null || !(key in current)) { + return false; } - return true; + current = current[key]; } - }; - + return true; + } +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index ae09f855..0150ff86 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,14 +21,6 @@ export default [ '**/__tests__/kitchen-sink/**' ] }, - { - // template literals holding code that is emitted verbatim; changing the - // escaping here would change the generated output - files: ['packages/proto-parser/src/inline-helpers/**'], - rules: { - 'no-useless-escape': 'off' - } - }, { // per-version transformers mirror the AST shape field for field, including // identity assignments and empty node payloads such as `{ Null: {} }` diff --git a/packages/proto-parser/__tests__/__snapshots__/utils.test.ts.snap b/packages/proto-parser/__tests__/__snapshots__/utils.test.ts.snap index 236d8e11..8db889d7 100644 --- a/packages/proto-parser/__tests__/__snapshots__/utils.test.ts.snap +++ b/packages/proto-parser/__tests__/__snapshots__/utils.test.ts.snap @@ -13146,49 +13146,60 @@ export interface ScanToken { * and run the pg-proto-parser generate command to regenerate this file. */ +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function parsePath(path: string): string[] { + const keys = path.replace(/\\[(\\w+)\\]/g, '.$1').split('.'); + for (const key of keys) { + if (UNSAFE_KEYS.has(key)) { + throw new Error('Unsafe path segment: ' + key); + } + } + return keys; +} + export default { - get(obj: Record, path: string): T | undefined { - const keys = path.replace(/[(w+)]/g, '.$1').split('.'); - let result: any = obj; - for (const key of keys) { - if (result == null) { - return undefined; - } - result = result[key]; - } - return result as T; - }, - - set(obj: Record, path: string, value: any): void { - if (value === undefined) { - return; + get(obj: Record, path: string): T | undefined { + const keys = parsePath(path); + let result: any = obj; + for (const key of keys) { + if (result == null) { + return undefined; } - - const keys = path.replace(/[(w+)]/g, '.$1').split('.'); - let current = obj; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (typeof current[key] !== 'object') { - current[key] = {}; - } - current = current[key]; + result = result[key]; + } + return result as T; + }, + + set(obj: Record, path: string, value: any): void { + if (value === undefined) { + return; + } + + const keys = parsePath(path); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (typeof current[key] !== 'object') { + current[key] = {}; } - current[keys[keys.length - 1]] = value; - }, - - has(obj: Record, path: string): boolean { - const keys = path.replace(/[(w+)]/g, '.$1').split('.'); - let current = obj; - for (const key of keys) { - if (current == null || !(key in current)) { - return false; - } - current = current[key]; + current = current[key]; + } + current[keys[keys.length - 1]] = value; + }, + + has(obj: Record, path: string): boolean { + const keys = parsePath(path); + let current = obj; + for (const key of keys) { + if (current == null || !(key in current)) { + return false; } - return true; + current = current[key]; } - }; - + return true; + } +}; ", "file": "path-obj.ts", }, diff --git a/packages/proto-parser/__tests__/inline-helpers.test.ts b/packages/proto-parser/__tests__/inline-helpers.test.ts new file mode 100644 index 00000000..b20aec3a --- /dev/null +++ b/packages/proto-parser/__tests__/inline-helpers.test.ts @@ -0,0 +1,16 @@ +import { nestedObjCode } from '../src/inline-helpers'; + +describe('inlined nested-obj helper', () => { + it('emits the bracket-notation regex unescaped by the template literal', () => { + expect(nestedObjCode).toContain('path.replace(/\\[(\\w+)\\]/g, \'.$1\')'); + }); + + it('guards every accessor against prototype pollution', () => { + expect(nestedObjCode).toContain( + 'const UNSAFE_KEYS = new Set([\'__proto__\', \'constructor\', \'prototype\']);' + ); + expect(nestedObjCode).toContain('throw new Error(\'Unsafe path segment: \' + key);'); + // get, set and has all route through the validating parser + expect(nestedObjCode.match(/const keys = parsePath\(path\);/g)).toHaveLength(3); + }); +}); diff --git a/packages/proto-parser/src/inline-helpers/nested-obj.ts b/packages/proto-parser/src/inline-helpers/nested-obj.ts index f8b2a658..cae06228 100644 --- a/packages/proto-parser/src/inline-helpers/nested-obj.ts +++ b/packages/proto-parser/src/inline-helpers/nested-obj.ts @@ -1,45 +1,59 @@ +// Mirrors the published `nested-obj` package (>= 0.2.2), including its +// prototype-pollution guard. Backslashes are doubled so the emitted file +// contains the intended regex. export const nestedObjCode = ` +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function parsePath(path: string): string[] { + const keys = path.replace(/\\[(\\w+)\\]/g, '.$1').split('.'); + for (const key of keys) { + if (UNSAFE_KEYS.has(key)) { + throw new Error('Unsafe path segment: ' + key); + } + } + return keys; +} + export default { - get(obj: Record, path: string): T | undefined { - const keys = path.replace(/\[(\w+)\]/g, '.$1').split('.'); - let result: any = obj; - for (const key of keys) { - if (result == null) { - return undefined; - } - result = result[key]; - } - return result as T; - }, - - set(obj: Record, path: string, value: any): void { - if (value === undefined) { - return; + get(obj: Record, path: string): T | undefined { + const keys = parsePath(path); + let result: any = obj; + for (const key of keys) { + if (result == null) { + return undefined; } - - const keys = path.replace(/\[(\w+)\]/g, '.$1').split('.'); - let current = obj; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (typeof current[key] !== 'object') { - current[key] = {}; - } - current = current[key]; + result = result[key]; + } + return result as T; + }, + + set(obj: Record, path: string, value: any): void { + if (value === undefined) { + return; + } + + const keys = parsePath(path); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (typeof current[key] !== 'object') { + current[key] = {}; } - current[keys[keys.length - 1]] = value; - }, - - has(obj: Record, path: string): boolean { - const keys = path.replace(/\[(\w+)\]/g, '.$1').split('.'); - let current = obj; - for (const key of keys) { - if (current == null || !(key in current)) { - return false; - } - current = current[key]; + current = current[key]; + } + current[keys[keys.length - 1]] = value; + }, + + has(obj: Record, path: string): boolean { + const keys = parsePath(path); + let current = obj; + for (const key of keys) { + if (current == null || !(key in current)) { + return false; } - return true; + current = current[key]; } - }; - -`; \ No newline at end of file + return true; + } +}; +`;