From 6b3d8245c757f81185d008287442a812a5c204fb Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 18 Aug 2026 23:33:56 +0000 Subject: [PATCH 1/6] fix(security): harden handwritten SDK trust boundaries --- src/bedrock.ts | 5 ++ src/internal/qs/utils.ts | 15 +++++- src/lib/Util.ts | 12 +++-- tests/generated-resource-helpers.test.ts | 37 ++++++++++++++- tests/lib/Util.test.ts | 31 +++++++++++-- tests/lib/bedrock.test.ts | 46 +++++++++++++++++++ tests/qs/utils.test.ts | 58 +++++++++++++++++++++++- 7 files changed, 191 insertions(+), 13 deletions(-) diff --git a/src/bedrock.ts b/src/bedrock.ts index 6a938eef6e..9d7db303de 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -68,6 +68,11 @@ function deriveBedrockBaseURL(awsRegion: string | undefined): string { 'Must provide one of the `baseURL` or `awsRegion` arguments, or set the `AWS_BEDROCK_BASE_URL`, `AWS_REGION`, or `AWS_DEFAULT_REGION` environment variable.', ); } + if (!/^[a-z]{2,8}(?:-[a-z0-9]+)+-\d+$/u.test(region)) { + throw new Errors.OpenAIError( + 'The Bedrock AWS `region` is invalid. Use a standard AWS region such as `us-east-1`.', + ); + } return `https://bedrock-mantle.${region}.api.aws/openai/v1`; } diff --git a/src/internal/qs/utils.ts b/src/internal/qs/utils.ts index 9caedd9dc6..51f04db240 100644 --- a/src/internal/qs/utils.ts +++ b/src/internal/qs/utils.ts @@ -11,6 +11,10 @@ export const has = (obj: object, key: PropertyKey): boolean => { return resolvedHas(obj, key); }; +function isUnsafePropertyKey(key: unknown): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} + const hex_table = /* @__PURE__ */ (() => { const array = []; for (let i = 0; i < 256; ++i) { @@ -68,7 +72,10 @@ export function merge( if (isArray(target)) { target.push(source); } else if (target && typeof target === 'object') { - if ((options && (options.plainObjects || options.allowPrototypes)) || !has(Object.prototype, source)) { + if ( + !isUnsafePropertyKey(source) && + ((options && (options.plainObjects || options.allowPrototypes)) || !has(Object.prototype, source)) + ) { target[source] = true; } } else { @@ -110,6 +117,9 @@ export function merge( } for (const key of Object.keys(source)) { + if (isUnsafePropertyKey(key)) { + continue; + } const value = source[key]; mergeTarget[key] = has(mergeTarget, key) ? merge(mergeTarget[key], value, options) : value; @@ -119,6 +129,9 @@ export function merge( export function assign_single_source(target: any, source: any) { for (const key of Object.keys(source)) { + if (isUnsafePropertyKey(key)) { + continue; + } target[key] = source[key]; } return target; diff --git a/src/lib/Util.ts b/src/lib/Util.ts index ae09b8a911..ff3d78a390 100644 --- a/src/lib/Util.ts +++ b/src/lib/Util.ts @@ -1,15 +1,17 @@ /** * Like `Promise.allSettled()` but throws an error if any promises are rejected. + * Rejection reasons remain available on the thrown error's non-enumerable `rejections` + * property without being written to the global console. */ export const allSettledWithThrow = async (promises: Promise[]): Promise => { const results = await Promise.allSettled(promises); const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); if (rejected.length) { - for (const result of rejected) { - console.error(result.reason); - } - - throw new Error(`${rejected.length} promise(s) failed - see the above errors`); + throw Object.defineProperty(new Error(`${rejected.length} promise(s) failed`), 'rejections', { + configurable: true, + value: rejected.map(({ reason }) => reason), + writable: true, + }); } // Note: TS was complaining about using `.filter().map()` here for some reason diff --git a/tests/generated-resource-helpers.test.ts b/tests/generated-resource-helpers.test.ts index 07cac550db..60c30de4e9 100644 --- a/tests/generated-resource-helpers.test.ts +++ b/tests/generated-resource-helpers.test.ts @@ -252,9 +252,44 @@ describe('vector store file batch helpers', () => { await expect(batches.uploadAndPoll('vs_123', { files: [new File(['a'], 'a.txt')] })).rejects.toThrow( '1 promise(s) failed', ); - expect(logError).toHaveBeenCalledWith(expect.objectContaining({ message: 'upload failed' })); + expect(logError).not.toHaveBeenCalled(); expect(createAndPoll).not.toHaveBeenCalled(); }); + + test('keeps sensitive concurrent upload failures out of disabled logs', async () => { + const secret = 'sk-synthetic-private-upload-secret'; + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const fetch = vi.fn(async (_url: string | URL | Request) => + Response.json( + { error: { message: `Provider rejected ${secret}`, type: 'invalid_request_error' } }, + { status: 400, headers: { 'set-cookie': `session=${secret}` } }, + ), + ); + const client = new OpenAI({ + apiKey: 'sk-synthetic-client-key', + baseURL: 'https://example.com/v1/', + logLevel: 'off', + logger, + maxRetries: 0, + fetch, + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const upload = client.vectorStores.fileBatches.uploadAndPoll( + 'vs_123', + { files: [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')] }, + { maxConcurrency: 2 }, + ); + + await expect(upload).rejects.toThrow('2 promise(s) failed'); + expect(consoleError).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(fetch.mock.calls.filter(([url]) => String(url).endsWith('/v1/files'))).toHaveLength(2); + await expect(upload).rejects.toMatchObject({ + name: 'Error', + message: '2 promise(s) failed', + rejections: [expect.objectContaining({ status: 400 }), expect.objectContaining({ status: 400 })], + }); + }); }); describe('assistant run helpers', () => { diff --git a/tests/lib/Util.test.ts b/tests/lib/Util.test.ts index 07017d63af..7cebc1fc69 100644 --- a/tests/lib/Util.test.ts +++ b/tests/lib/Util.test.ts @@ -1,3 +1,5 @@ +import { inspect } from 'node:util'; + import { vi } from 'vitest'; import { allSettledWithThrow } from 'openai/lib/Util'; @@ -12,7 +14,7 @@ describe('allSettledWithThrow', () => { await expect(allSettledWithThrow([])).resolves.toEqual([]); }); - test('logs every rejection and reports how many promises failed', async () => { + test('preserves every rejection without logging sensitive errors', async () => { const firstError = new Error('first failure'); const secondError = new Error('second failure'); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -20,13 +22,32 @@ describe('allSettledWithThrow', () => { try { await expect( allSettledWithThrow([Promise.reject(firstError), Promise.resolve('ok'), Promise.reject(secondError)]), - ).rejects.toThrow('2 promise(s) failed - see the above errors'); + ).rejects.toMatchObject({ + name: 'Error', + message: '2 promise(s) failed', + rejections: [firstError, secondError], + }); - expect(consoleError).toHaveBeenNthCalledWith(1, firstError); - expect(consoleError).toHaveBeenNthCalledWith(2, secondError); - expect(consoleError).toHaveBeenCalledTimes(2); + expect(consoleError).not.toHaveBeenCalled(); } finally { consoleError.mockRestore(); } }); + + test('keeps rejected errors out of serialized aggregate failures', async () => { + const secret = 'sk-synthetic-private-upload-secret'; + const rejected = allSettledWithThrow([Promise.reject(new Error(secret))]); + const error = await rejected.catch((caughtError: unknown) => caughtError); + + expect(error).toBeInstanceOf(Error); + + if (!(error instanceof Error)) { + throw new Error('Expected the promise to reject with an Error'); + } + + expect(Object.getOwnPropertyDescriptor(error, 'rejections')?.enumerable).toBe(false); + expect(JSON.stringify(error)).not.toContain(secret); + expect(inspect(error)).not.toContain(secret); + expect(error.message).not.toContain(secret); + }); }); diff --git a/tests/lib/bedrock.test.ts b/tests/lib/bedrock.test.ts index 27142aace3..158764bff2 100644 --- a/tests/lib/bedrock.test.ts +++ b/tests/lib/bedrock.test.ts @@ -104,6 +104,52 @@ describe('instantiate bedrock client', () => { expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); }); + test.each([ + ['path injection', 'attacker.example/'], + ['userinfo injection', 'us-east-1@attacker.example/'], + ['backslash injection', 'attacker.example\\'], + ['query injection', 'us-east-1?target=attacker.example'], + ['fragment injection', 'us-east-1#attacker.example'], + ['a malformed region', 'not-a-region'], + ])('rejects %s in an explicit AWS region before resolving credentials', (_scenario, awsRegion) => { + const bedrockTokenProvider = vi.fn(async () => 'synthetic-bedrock-secret'); + const fetch = vi.fn(async () => jsonResponse()); + + expect(() => new BedrockOpenAI({ awsRegion, bedrockTokenProvider, fetch })).toThrow(/region.*invalid/iu); + expect(bedrockTokenProvider).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each(['AWS_REGION', 'AWS_DEFAULT_REGION'] as const)( + 'rejects an injected AWS region from %s before resolving credentials', + (environmentVariable) => { + process.env[environmentVariable] = 'us-east-1@attacker.example/'; + const bedrockTokenProvider = vi.fn(async () => 'synthetic-bedrock-secret'); + const fetch = vi.fn(async () => jsonResponse()); + + expect(() => new BedrockOpenAI({ bedrockTokenProvider, fetch })).toThrow(/region.*invalid/iu); + expect(bedrockTokenProvider).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each(['us-east-1', 'us-gov-west-1', 'cn-north-1'])( + 'preserves the derived Bedrock endpoint for the valid AWS region %s', + (awsRegion) => { + const client = new BedrockOpenAI({ awsRegion, apiKey: 'token' }); + + expect(client.baseURL).toBe(`https://bedrock-mantle.${awsRegion}.api.aws/openai/v1`); + }, + ); + + test('preserves an explicitly configured endpoint when the ambient AWS region is invalid', () => { + process.env['AWS_REGION'] = 'attacker.example/'; + + const client = new BedrockOpenAI({ baseURL: 'https://trusted.example/openai/v1', apiKey: 'token' }); + + expect(client.baseURL).toBe('https://trusted.example/openai/v1'); + }); + test('uses Bedrock config precedence', () => { process.env['AWS_BEDROCK_BASE_URL'] = 'https://env.example.com/openai/v1'; process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'env token'; diff --git a/tests/qs/utils.test.ts b/tests/qs/utils.test.ts index 847355287b..160a602771 100644 --- a/tests/qs/utils.test.ts +++ b/tests/qs/utils.test.ts @@ -1,4 +1,4 @@ -import { combine, merge, is_buffer, assign_single_source } from 'openai/internal/qs/utils'; +import { combine, merge, is_buffer, assign_single_source, has } from 'openai/internal/qs/utils'; describe('merge()', () => { // t.deepEqual(merge(null, true), [null, true], 'merges true into null'); @@ -85,6 +85,62 @@ test('assign()', () => { expect(source).toEqual({ b: 3, c: 4 }); }); +describe('prototype-pollution safety', () => { + test.each([ + { + name: 'merge', + apply: (target: Record, source: Record) => merge(target, source), + }, + { + name: 'assign_single_source', + apply: (target: Record, source: Record) => + assign_single_source(target, source), + }, + ])('$name preserves inherited properties while ignoring unsafe own source keys', ({ apply }) => { + const inherited = { inherited: true }; + const target = Object.create(inherited); + const source = JSON.parse( + '{"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}},"prototype":{"polluted":true},"safe":"preserved"}', + ); + + expect(apply(target, source)).toBe(target); + expect(Object.getPrototypeOf(target)).toBe(inherited); + expect(target.inherited).toBe(true); + expect(target.safe).toBe('preserved'); + expect(has(target, '__proto__')).toBe(false); + expect(has(target, 'constructor')).toBe(false); + expect(has(target, 'prototype')).toBe(false); + expect(Reflect.get(Object.prototype, 'polluted')).toBeUndefined(); + }); + + test('merge ignores unsafe keys in nested objects', () => { + const target = { nested: {} }; + const source = JSON.parse( + '{"nested":{"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}},"prototype":{"polluted":true},"safe":true}}', + ); + + expect(merge(target, source)).toBe(target); + expect(Object.getPrototypeOf(target.nested)).toBe(Object.prototype); + expect(target.nested).toEqual({ safe: true }); + expect(Reflect.get(Object.prototype, 'polluted')).toBeUndefined(); + }); + + test.each(['__proto__', 'constructor', 'prototype'])( + 'merge ignores unsafe scalar key %s when inherited property names are allowed', + (key) => { + const target = {}; + + expect(merge(target, key, { allowPrototypes: true })).toBe(target); + expect(Object.getPrototypeOf(target)).toBe(Object.prototype); + expect(has(target, key)).toBe(false); + }, + ); + + test('merge preserves explicitly allowed safe inherited property names', () => { + expect(merge({}, 'toString', { allowPrototypes: true })).toEqual({ toString: true }); + }); +}); + describe('combine()', () => { test('both arrays', () => { const a = [1]; From 2e1493b33cc09c901db5796e757ad9a83670030b Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 18 Aug 2026 23:49:32 +0000 Subject: [PATCH 2/6] fix(qs): safely sanitize newly adopted nested records --- src/internal/qs/utils.ts | 103 +++++++++++++++++++++++++++++++++++++-- tests/qs/utils.test.ts | 94 +++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 4 deletions(-) diff --git a/src/internal/qs/utils.ts b/src/internal/qs/utils.ts index 51f04db240..55bea57b40 100644 --- a/src/internal/qs/utils.ts +++ b/src/internal/qs/utils.ts @@ -15,6 +15,98 @@ function isUnsafePropertyKey(key: unknown): boolean { return key === '__proto__' || key === 'constructor' || key === 'prototype'; } +interface AdoptedRecord { + value: object; + descriptors: PropertyDescriptorMap; + parents: Set; + unsafe: boolean; +} + +function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any { + if (!value || typeof value !== 'object') { + return value; + } + if (sanitized.has(value)) { + return sanitized.get(value); + } + + const records = new WeakMap(); + const visited: AdoptedRecord[] = []; + const unsafe: AdoptedRecord[] = []; + function inspect(candidate: object): AdoptedRecord { + const existing = records.get(candidate); + if (existing) { + return existing; + } + + const record: AdoptedRecord = { + value: candidate, + descriptors: Object.getOwnPropertyDescriptors(candidate), + parents: new Set(), + unsafe: false, + }; + records.set(candidate, record); + visited.push(record); + + for (const key of Reflect.ownKeys(record.descriptors)) { + if (isUnsafePropertyKey(key)) { + record.unsafe = true; + continue; + } + const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; + if ('value' in descriptor && descriptor.value && typeof descriptor.value === 'object') { + if (sanitized.has(descriptor.value)) { + record.unsafe ||= sanitized.get(descriptor.value) !== descriptor.value; + } else { + inspect(descriptor.value).parents.add(record); + } + } + } + if (record.unsafe) { + unsafe.push(record); + } + return record; + } + + const root = inspect(value); + for (const record of unsafe) { + for (const parent of record.parents) { + if (!parent.unsafe) { + parent.unsafe = true; + unsafe.push(parent); + } + } + } + for (const record of visited) { + if (!record.unsafe) { + sanitized.set(record.value, record.value); + } + } + + function copy(record: AdoptedRecord): any { + if (sanitized.has(record.value)) { + return sanitized.get(record.value); + } + const result = isArray(record.value) ? [] : Object.create(Object.getPrototypeOf(record.value)); + sanitized.set(record.value, result); + + for (const key of Reflect.ownKeys(record.descriptors)) { + if (isUnsafePropertyKey(key)) { + continue; + } + const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; + if ('value' in descriptor && descriptor.value && typeof descriptor.value === 'object') { + const child = records.get(descriptor.value); + descriptor.value = child ? copy(child) : sanitized.get(descriptor.value); + } + Object.defineProperty(result, key, descriptor); + } + return result; + } + + return root.unsafe ? copy(root) : value; +} + const hex_table = /* @__PURE__ */ (() => { const array = []; for (let i = 0; i < 256; ++i) { @@ -85,9 +177,10 @@ export function merge( return target; } + const sanitizedValues = new WeakMap(); if (!target || typeof target !== 'object') { // oxlint-disable-next-line unicorn/prefer-spread -- concat intentionally preserves one-level flattening and sparse-array behavior. - return [target].concat(source); + return [target].concat(sanitizeAdoptedValue(source, sanitizedValues)); } let mergeTarget = target; @@ -106,10 +199,10 @@ export function merge( if (targetItem && typeof targetItem === 'object' && item && typeof item === 'object') { target[i] = merge(targetItem, item, options); } else { - target.push(item); + target.push(sanitizeAdoptedValue(item, sanitizedValues)); } } else { - target[i] = item; + target[i] = sanitizeAdoptedValue(item, sanitizedValues); } } } @@ -122,7 +215,9 @@ export function merge( } const value = source[key]; - mergeTarget[key] = has(mergeTarget, key) ? merge(mergeTarget[key], value, options) : value; + mergeTarget[key] = has(mergeTarget, key) + ? merge(mergeTarget[key], value, options) + : sanitizeAdoptedValue(value, sanitizedValues); } return mergeTarget; } diff --git a/tests/qs/utils.test.ts b/tests/qs/utils.test.ts index 160a602771..8d9e1a0443 100644 --- a/tests/qs/utils.test.ts +++ b/tests/qs/utils.test.ts @@ -125,6 +125,100 @@ describe('prototype-pollution safety', () => { expect(Reflect.get(Object.prototype, 'polluted')).toBeUndefined(); }); + test('sanitizes newly adopted nested records without changing the source', () => { + const source = JSON.parse( + '{"nested":{"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}},"prototype":{"polluted":true},"safe":{"value":true}}}', + ); + const result = merge({}, source); + const destination = {}; + + expect(result.nested).not.toBe(source.nested); + expect(result.nested).toEqual({ safe: { value: true } }); + expect(Object.assign(destination, result.nested)).toBe(destination); + expect(Object.getPrototypeOf(destination)).toBe(Object.prototype); + expect(has(source.nested, '__proto__')).toBe(true); + expect(has(source.nested, 'constructor')).toBe(true); + expect(has(source.nested, 'prototype')).toBe(true); + }); + + test.each([ + { + name: 'nested array entries', + apply: (unsafe: Record) => merge({}, { nested: [unsafe] }).nested[0], + }, + { + name: 'newly assigned array entries', + apply: (unsafe: Record) => merge([], [unsafe])[0], + }, + { + name: 'array entries appended after a scalar collision', + apply: (unsafe: Record) => merge(['existing'], [unsafe])[1], + }, + { + name: 'objects adopted after a scalar target', + apply: (unsafe: Record) => merge('existing', unsafe)[1], + }, + { + name: 'flattened array entries after a scalar target', + apply: (unsafe: Record) => merge('existing', [unsafe])[1], + }, + ])('sanitizes $name', ({ apply }) => { + const unsafe = JSON.parse('{"__proto__":{"polluted":true},"safe":true}'); + const result = apply(unsafe); + + expect(result).toEqual({ safe: true }); + expect(has(result, '__proto__')).toBe(false); + expect(has(unsafe, '__proto__')).toBe(true); + const destination = {}; + expect(Object.assign(destination, result)).toBe(destination); + expect(Object.getPrototypeOf(destination)).toBe(Object.prototype); + }); + + test('preserves safe adopted identities, array holes, and null prototypes', () => { + const safe = Object.assign(Object.create(null), { value: true }); + const sparse: unknown[] = []; + sparse[2] = safe; + const result = merge({}, { safe, sparse }); + + expect(result.safe).toBe(safe); + expect(result.sparse).toBe(sparse); + expect(0 in result.sparse).toBe(false); + expect(1 in result.sparse).toBe(false); + expect(Object.getPrototypeOf(result.safe)).toBeNull(); + }); + + test('preserves cycles and shared references when sanitizing adopted records', () => { + const unsafe: Record = JSON.parse('{"__proto__":{"polluted":true},"safe":true}'); + unsafe['self'] = unsafe; + const result = merge({}, { left: unsafe, right: unsafe }); + + expect(result.left).toBe(result.right); + expect(result.left.self).toBe(result.left); + expect(result.left.safe).toBe(true); + expect(has(result.left, '__proto__')).toBe(false); + expect(unsafe['self']).toBe(unsafe); + expect(has(unsafe, '__proto__')).toBe(true); + }); + + test('copies adopted getter descriptors without invoking untrusted getters', () => { + const unsafe = JSON.parse('{"__proto__":{"polluted":true},"safe":true}'); + let calls = 0; + Object.defineProperty(unsafe, 'observed', { + configurable: true, + enumerable: true, + get() { + calls += 1; + return 'value'; + }, + }); + const getter = Object.getOwnPropertyDescriptor(unsafe, 'observed')?.get; + const result = merge({}, { nested: unsafe }); + + expect(calls).toBe(0); + expect(Object.getOwnPropertyDescriptor(result.nested, 'observed')?.get).toBe(getter); + expect(has(result.nested, '__proto__')).toBe(false); + }); + test.each(['__proto__', 'constructor', 'prototype'])( 'merge ignores unsafe scalar key %s when inherited property names are allowed', (key) => { From 648923b26d9cf4c79d0ed1bc3c3c2ad9c4e02595 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 18 Aug 2026 23:59:54 +0000 Subject: [PATCH 3/6] fix(qs): bound and revalidate shared record sanitation --- src/internal/qs/utils.ts | 126 +++++++++++++++++++++++++++++++-------- tests/qs/utils.test.ts | 89 +++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 24 deletions(-) diff --git a/src/internal/qs/utils.ts b/src/internal/qs/utils.ts index 55bea57b40..ac6bc90ead 100644 --- a/src/internal/qs/utils.ts +++ b/src/internal/qs/utils.ts @@ -22,12 +22,23 @@ interface AdoptedRecord { unsafe: boolean; } +interface MergeState { + sanitized: WeakMap; + adopted: { target: object; key: PropertyKey }[]; +} + +const maxAdoptedRecords = 10_000; + function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any { if (!value || typeof value !== 'object') { return value; } if (sanitized.has(value)) { - return sanitized.get(value); + const cached = sanitized.get(value); + if (cached !== value) { + return cached; + } + sanitized.delete(value); } const records = new WeakMap(); @@ -38,6 +49,9 @@ function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any if (existing) { return existing; } + if (visited.length >= maxAdoptedRecords) { + throw new Error('Adopted record traversal exceeds the supported safety limit.'); + } const record: AdoptedRecord = { value: candidate, @@ -47,7 +61,11 @@ function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any }; records.set(candidate, record); visited.push(record); + return record; + } + const root = inspect(value); + for (const record of visited) { for (const key of Reflect.ownKeys(record.descriptors)) { if (isUnsafePropertyKey(key)) { record.unsafe = true; @@ -56,19 +74,20 @@ function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; if ('value' in descriptor && descriptor.value && typeof descriptor.value === 'object') { if (sanitized.has(descriptor.value)) { - record.unsafe ||= sanitized.get(descriptor.value) !== descriptor.value; - } else { - inspect(descriptor.value).parents.add(record); + if (sanitized.get(descriptor.value) !== descriptor.value) { + record.unsafe = true; + continue; + } + sanitized.delete(descriptor.value); } + inspect(descriptor.value).parents.add(record); } } if (record.unsafe) { unsafe.push(record); } - return record; } - const root = inspect(value); for (const record of unsafe) { for (const parent of record.parents) { if (!parent.unsafe) { @@ -82,14 +101,33 @@ function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any sanitized.set(record.value, record.value); } } + if (!root.unsafe) { + return value; + } - function copy(record: AdoptedRecord): any { + const pendingCopies: AdoptedRecord[] = []; + function createCopy(record: AdoptedRecord): any { if (sanitized.has(record.value)) { return sanitized.get(record.value); } - const result = isArray(record.value) ? [] : Object.create(Object.getPrototypeOf(record.value)); + const prototype = Object.getPrototypeOf(record.value); + const array = isArray(record.value); + if ( + (array && prototype !== Array.prototype) || + (!array && prototype !== Object.prototype && prototype !== null) + ) { + throw new TypeError('Cannot safely sanitize an adopted record with an unsupported prototype.'); + } + + const result = array ? [] : Object.create(prototype); sanitized.set(record.value, result); + pendingCopies.push(record); + return result; + } + const result = createCopy(root); + for (const record of pendingCopies) { + const copy = sanitized.get(record.value); for (const key of Reflect.ownKeys(record.descriptors)) { if (isUnsafePropertyKey(key)) { continue; @@ -97,14 +135,22 @@ function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; if ('value' in descriptor && descriptor.value && typeof descriptor.value === 'object') { const child = records.get(descriptor.value); - descriptor.value = child ? copy(child) : sanitized.get(descriptor.value); + descriptor.value = child ? createCopy(child) : sanitized.get(descriptor.value); } - Object.defineProperty(result, key, descriptor); + Object.defineProperty(copy, key, descriptor); } - return result; } + return result; +} - return root.unsafe ? copy(root) : value; +function rememberAdoption(state: MergeState, target: object, key: PropertyKey, value: any): void { + if (!value || typeof value !== 'object') { + return; + } + if (state.adopted.length >= maxAdoptedRecords) { + throw new Error('Adopted record traversal exceeds the supported safety limit.'); + } + state.adopted.push({ target, key }); } const hex_table = /* @__PURE__ */ (() => { @@ -151,11 +197,12 @@ function array_to_object(source: any[], options: { plainObjects: boolean }) { return obj; } -export function merge( +function mergeWithState( target: any, source: any, - options: { plainObjects?: boolean; allowPrototypes?: boolean } = {}, -) { + options: { plainObjects?: boolean; allowPrototypes?: boolean }, + state: MergeState, +): any { if (!source) { return target; } @@ -177,10 +224,13 @@ export function merge( return target; } - const sanitizedValues = new WeakMap(); if (!target || typeof target !== 'object') { // oxlint-disable-next-line unicorn/prefer-spread -- concat intentionally preserves one-level flattening and sparse-array behavior. - return [target].concat(sanitizeAdoptedValue(source, sanitizedValues)); + const combined = [target].concat(sanitizeAdoptedValue(source, state.sanitized)); + for (const [index, item] of combined.entries()) { + rememberAdoption(state, combined, index, item); + } + return combined; } let mergeTarget = target; @@ -197,12 +247,18 @@ export function merge( if (has(target, i)) { const targetItem = target[i]; if (targetItem && typeof targetItem === 'object' && item && typeof item === 'object') { - target[i] = merge(targetItem, item, options); + const merged = mergeWithState(targetItem, item, options, state); + target[i] = merged; + rememberAdoption(state, target, i, merged); } else { - target.push(sanitizeAdoptedValue(item, sanitizedValues)); + const adopted = sanitizeAdoptedValue(item, state.sanitized); + target.push(adopted); + rememberAdoption(state, target, target.length - 1, adopted); } } else { - target[i] = sanitizeAdoptedValue(item, sanitizedValues); + const adopted = sanitizeAdoptedValue(item, state.sanitized); + target[i] = adopted; + rememberAdoption(state, target, i, adopted); } } } @@ -214,14 +270,36 @@ export function merge( continue; } const value = source[key]; - - mergeTarget[key] = has(mergeTarget, key) - ? merge(mergeTarget[key], value, options) - : sanitizeAdoptedValue(value, sanitizedValues); + const adopted = has(mergeTarget, key) + ? mergeWithState(mergeTarget[key], value, options, state) + : sanitizeAdoptedValue(value, state.sanitized); + mergeTarget[key] = adopted; + rememberAdoption(state, mergeTarget, key, adopted); } return mergeTarget; } +export function merge( + target: any, + source: any, + options: { plainObjects?: boolean; allowPrototypes?: boolean } = {}, +) { + const state: MergeState = { sanitized: new WeakMap(), adopted: [] }; + const result = mergeWithState(target, source, options, state); + + for (const { target: adoptedTarget, key } of state.adopted) { + const descriptor = Object.getOwnPropertyDescriptor(adoptedTarget, key); + if (!descriptor || !('value' in descriptor)) { + continue; + } + const safe = sanitizeAdoptedValue(descriptor.value, state.sanitized); + if (safe !== descriptor.value) { + Object.defineProperty(adoptedTarget, key, { ...descriptor, value: safe }); + } + } + return result; +} + export function assign_single_source(target: any, source: any) { for (const key of Object.keys(source)) { if (isUnsafePropertyKey(key)) { diff --git a/tests/qs/utils.test.ts b/tests/qs/utils.test.ts index 8d9e1a0443..2a3b832467 100644 --- a/tests/qs/utils.test.ts +++ b/tests/qs/utils.test.ts @@ -219,6 +219,95 @@ describe('prototype-pollution safety', () => { expect(has(result.nested, '__proto__')).toBe(false); }); + test('revalidates and replaces every alias after a source getter mutates a safe record', () => { + const shared: Record = { safe: true }; + let getterCalls = 0; + const source = { + first: shared, + get mutate() { + getterCalls += 1; + Object.defineProperty(shared, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + return true; + }, + second: shared, + }; + const result = merge({}, source); + + expect(getterCalls).toBe(1); + expect(result.first).toBe(result.second); + expect(result.first).not.toBe(shared); + expect(has(result.first, '__proto__')).toBe(false); + expect(has(result.second, '__proto__')).toBe(false); + expect(has(shared, '__proto__')).toBe(true); + }); + + test('shares sanitized aliases across recursive collisions and new adoptions', () => { + const unsafe = JSON.parse('{"__proto__":{"polluted":true},"safe":true}'); + const result = merge({ first: 'existing' }, { first: unsafe, second: unsafe }); + + expect(result.first).toEqual(['existing', { safe: true }]); + expect(result.first[1]).toBe(result.second); + expect(has(result.second, '__proto__')).toBe(false); + expect(has(unsafe, '__proto__')).toBe(true); + }); + + test.each([ + { name: 'Date', create: () => new Date('2026-08-18T00:00:00.000Z') }, + { name: 'Map', create: () => new Map([['safe', true]]) }, + { name: 'Set', create: () => new Set(['safe']) }, + { name: 'typed array', create: () => new Uint8Array([1, 2]) }, + { + name: 'private-field instance', + create: () => + new (class { + #value = true; + getValue() { + return this.#value; + } + })(), + }, + ])( + 'preserves safe $name identity and rejects unsafe values without forging internal slots', + ({ create }) => { + const value = create(); + + expect(merge({}, { safe: value }).safe).toBe(value); + Object.defineProperty(value, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + expect(() => merge({}, { unsafe: value })).toThrow(/safely sanitize|unsupported prototype/iu); + expect(has(value, '__proto__')).toBe(true); + }, + ); + + test('iteratively preserves safe adopted records deeper than the JavaScript call stack', () => { + const root: Record = {}; + let current = root; + for (let index = 0; index < 6000; index += 1) { + current['next'] = {}; + current = current['next']; + } + + expect(merge({}, { nested: root }).nested).toBe(root); + }); + + test('fails closed when adopted records exceed the bounded traversal budget', () => { + const root: Record = {}; + let current = root; + for (let index = 0; index < 10_001; index += 1) { + current['next'] = {}; + current = current['next']; + } + + expect(() => merge({}, { nested: root })).toThrow(/adopted record|traversal|limit/iu); + }); + test.each(['__proto__', 'constructor', 'prototype'])( 'merge ignores unsafe scalar key %s when inherited property names are allowed', (key) => { From ee47782a1031cdf56cc6eb248b96946a4b8466de Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 00:22:02 +0000 Subject: [PATCH 4/6] fix(qs): snapshot adopted graphs before publishing aliases --- src/internal/qs/utils.ts | 197 ++++++++++++++++++++------------------- tests/qs/utils.test.ts | 153 +++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 103 deletions(-) diff --git a/src/internal/qs/utils.ts b/src/internal/qs/utils.ts index ac6bc90ead..4cdac1f5d3 100644 --- a/src/internal/qs/utils.ts +++ b/src/internal/qs/utils.ts @@ -18,139 +18,148 @@ function isUnsafePropertyKey(key: unknown): boolean { interface AdoptedRecord { value: object; descriptors: PropertyDescriptorMap; + prototype: object | null; parents: Set; - unsafe: boolean; + ordinary: boolean; + array: boolean; + detached: boolean; } interface MergeState { - sanitized: WeakMap; adopted: { target: object; key: PropertyKey }[]; } const maxAdoptedRecords = 10_000; -function sanitizeAdoptedValue(value: any, sanitized: WeakMap): any { - if (!value || typeof value !== 'object') { - return value; +function isObjectLike(value: unknown): value is object { + return value !== null && (typeof value === 'object' || typeof value === 'function'); +} + +function rememberAdoption(state: MergeState, target: object, key: PropertyKey, value: any): void { + if (!isObjectLike(value)) { + return; } - if (sanitized.has(value)) { - const cached = sanitized.get(value); - if (cached !== value) { - return cached; - } - sanitized.delete(value); + if (state.adopted.length >= maxAdoptedRecords) { + throw new Error('Adopted record traversal exceeds the supported safety limit.'); + } + state.adopted.push({ target, key }); +} + +function sanitizeAdoptions(state: MergeState): void { + if (state.adopted.length === 0) { + return; } const records = new WeakMap(); const visited: AdoptedRecord[] = []; - const unsafe: AdoptedRecord[] = []; - function inspect(candidate: object): AdoptedRecord { - const existing = records.get(candidate); - if (existing) { - return existing; + const locations: { + target: object; + key: PropertyKey; + descriptor: PropertyDescriptor; + record: AdoptedRecord; + }[] = []; + let inspectedProperties = 0; + + function inspect(value: object): AdoptedRecord { + const known = records.get(value); + if (known) { + return known; } if (visited.length >= maxAdoptedRecords) { throw new Error('Adopted record traversal exceeds the supported safety limit.'); } + const descriptors = Object.getOwnPropertyDescriptors(value); + const prototype = Object.getPrototypeOf(value); + const array = isArray(value); + const ordinary = + typeof value !== 'function' && + (array ? prototype === Array.prototype : prototype === Object.prototype || prototype === null); const record: AdoptedRecord = { - value: candidate, - descriptors: Object.getOwnPropertyDescriptors(candidate), + value, + descriptors, + prototype, parents: new Set(), - unsafe: false, + ordinary, + array, + detached: ordinary && !Object.isFrozen(value), }; - records.set(candidate, record); + records.set(value, record); visited.push(record); return record; } - const root = inspect(value); + for (const { target, key } of state.adopted) { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor && 'value' in descriptor && isObjectLike(descriptor.value)) { + locations.push({ target, key, descriptor, record: inspect(descriptor.value) }); + } + } + + const detached: AdoptedRecord[] = []; for (const record of visited) { for (const key of Reflect.ownKeys(record.descriptors)) { - if (isUnsafePropertyKey(key)) { - record.unsafe = true; - continue; + inspectedProperties += 1; + if (inspectedProperties > maxAdoptedRecords) { + throw new Error('Adopted record traversal exceeds the supported safety limit.'); } const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; - if ('value' in descriptor && descriptor.value && typeof descriptor.value === 'object') { - if (sanitized.has(descriptor.value)) { - if (sanitized.get(descriptor.value) !== descriptor.value) { - record.unsafe = true; - continue; - } - sanitized.delete(descriptor.value); + if (isUnsafePropertyKey(key)) { + if (record.ordinary) { + record.detached = true; + } else if (descriptor.enumerable) { + throw new TypeError('Cannot safely sanitize an adopted callable or unsupported prototype.'); } + continue; + } + if (!record.ordinary && !descriptor.enumerable) { + continue; + } + if ('value' in descriptor && isObjectLike(descriptor.value)) { inspect(descriptor.value).parents.add(record); } } - if (record.unsafe) { - unsafe.push(record); + if (record.detached) { + detached.push(record); } } - for (const record of unsafe) { + for (const record of detached) { for (const parent of record.parents) { - if (!parent.unsafe) { - parent.unsafe = true; - unsafe.push(parent); + if (!parent.ordinary) { + throw new TypeError('Cannot safely sanitize an adopted callable or unsupported prototype.'); + } + if (!parent.detached) { + parent.detached = true; + detached.push(parent); } } } - for (const record of visited) { - if (!record.unsafe) { - sanitized.set(record.value, record.value); - } - } - if (!root.unsafe) { - return value; - } - - const pendingCopies: AdoptedRecord[] = []; - function createCopy(record: AdoptedRecord): any { - if (sanitized.has(record.value)) { - return sanitized.get(record.value); - } - const prototype = Object.getPrototypeOf(record.value); - const array = isArray(record.value); - if ( - (array && prototype !== Array.prototype) || - (!array && prototype !== Object.prototype && prototype !== null) - ) { - throw new TypeError('Cannot safely sanitize an adopted record with an unsupported prototype.'); - } - const result = array ? [] : Object.create(prototype); - sanitized.set(record.value, result); - pendingCopies.push(record); - return result; + const copies = new WeakMap(); + for (const record of detached) { + copies.set(record.value, record.array ? [] : Object.create(record.prototype)); } - - const result = createCopy(root); - for (const record of pendingCopies) { - const copy = sanitized.get(record.value); + for (const record of detached) { + const copy = copies.get(record.value); for (const key of Reflect.ownKeys(record.descriptors)) { if (isUnsafePropertyKey(key)) { continue; } const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; - if ('value' in descriptor && descriptor.value && typeof descriptor.value === 'object') { - const child = records.get(descriptor.value); - descriptor.value = child ? createCopy(child) : sanitized.get(descriptor.value); + if ('value' in descriptor && isObjectLike(descriptor.value) && copies.has(descriptor.value)) { + descriptor.value = copies.get(descriptor.value); } Object.defineProperty(copy, key, descriptor); } } - return result; -} -function rememberAdoption(state: MergeState, target: object, key: PropertyKey, value: any): void { - if (!value || typeof value !== 'object') { - return; - } - if (state.adopted.length >= maxAdoptedRecords) { - throw new Error('Adopted record traversal exceeds the supported safety limit.'); + for (const { target, key, descriptor, record } of locations) { + const value = copies.get(record.value) ?? record.value; + if (value !== descriptor.value) { + Object.defineProperty(copies.get(target) ?? target, key, { ...descriptor, value }); + } } - state.adopted.push({ target, key }); } const hex_table = /* @__PURE__ */ (() => { @@ -226,7 +235,7 @@ function mergeWithState( if (!target || typeof target !== 'object') { // oxlint-disable-next-line unicorn/prefer-spread -- concat intentionally preserves one-level flattening and sparse-array behavior. - const combined = [target].concat(sanitizeAdoptedValue(source, state.sanitized)); + const combined = [target].concat(source); for (const [index, item] of combined.entries()) { rememberAdoption(state, combined, index, item); } @@ -251,12 +260,12 @@ function mergeWithState( target[i] = merged; rememberAdoption(state, target, i, merged); } else { - const adopted = sanitizeAdoptedValue(item, state.sanitized); + const adopted = item; target.push(adopted); rememberAdoption(state, target, target.length - 1, adopted); } } else { - const adopted = sanitizeAdoptedValue(item, state.sanitized); + const adopted = item; target[i] = adopted; rememberAdoption(state, target, i, adopted); } @@ -270,9 +279,7 @@ function mergeWithState( continue; } const value = source[key]; - const adopted = has(mergeTarget, key) - ? mergeWithState(mergeTarget[key], value, options, state) - : sanitizeAdoptedValue(value, state.sanitized); + const adopted = has(mergeTarget, key) ? mergeWithState(mergeTarget[key], value, options, state) : value; mergeTarget[key] = adopted; rememberAdoption(state, mergeTarget, key, adopted); } @@ -284,29 +291,23 @@ export function merge( source: any, options: { plainObjects?: boolean; allowPrototypes?: boolean } = {}, ) { - const state: MergeState = { sanitized: new WeakMap(), adopted: [] }; + const state: MergeState = { adopted: [] }; const result = mergeWithState(target, source, options, state); - - for (const { target: adoptedTarget, key } of state.adopted) { - const descriptor = Object.getOwnPropertyDescriptor(adoptedTarget, key); - if (!descriptor || !('value' in descriptor)) { - continue; - } - const safe = sanitizeAdoptedValue(descriptor.value, state.sanitized); - if (safe !== descriptor.value) { - Object.defineProperty(adoptedTarget, key, { ...descriptor, value: safe }); - } - } + sanitizeAdoptions(state); return result; } export function assign_single_source(target: any, source: any) { + const state: MergeState = { adopted: [] }; for (const key of Object.keys(source)) { if (isUnsafePropertyKey(key)) { continue; } - target[key] = source[key]; + const value = source[key]; + target[key] = value; + rememberAdoption(state, target, key, value); } + sanitizeAdoptions(state); return target; } diff --git a/tests/qs/utils.test.ts b/tests/qs/utils.test.ts index 2a3b832467..b225e237e2 100644 --- a/tests/qs/utils.test.ts +++ b/tests/qs/utils.test.ts @@ -174,14 +174,15 @@ describe('prototype-pollution safety', () => { expect(Object.getPrototypeOf(destination)).toBe(Object.prototype); }); - test('preserves safe adopted identities, array holes, and null prototypes', () => { + test('snapshots mutable adopted records while preserving array holes and null prototypes', () => { const safe = Object.assign(Object.create(null), { value: true }); const sparse: unknown[] = []; sparse[2] = safe; const result = merge({}, { safe, sparse }); - expect(result.safe).toBe(safe); - expect(result.sparse).toBe(sparse); + expect(result.safe).not.toBe(safe); + expect(result.sparse).not.toBe(sparse); + expect(result.sparse[2]).toBe(result.safe); expect(0 in result.sparse).toBe(false); expect(1 in result.sparse).toBe(false); expect(Object.getPrototypeOf(result.safe)).toBeNull(); @@ -286,7 +287,7 @@ describe('prototype-pollution safety', () => { }, ); - test('iteratively preserves safe adopted records deeper than the JavaScript call stack', () => { + test('iteratively snapshots adopted records deeper than the JavaScript call stack', () => { const root: Record = {}; let current = root; for (let index = 0; index < 6000; index += 1) { @@ -294,7 +295,9 @@ describe('prototype-pollution safety', () => { current = current['next']; } - expect(merge({}, { nested: root }).nested).toBe(root); + const result = merge({}, { nested: root }); + expect(result.nested === root).toBe(false); + expect(typeof result.nested.next).toBe('object'); }); test('fails closed when adopted records exceed the bounded traversal budget', () => { @@ -308,6 +311,146 @@ describe('prototype-pollution safety', () => { expect(() => merge({}, { nested: root })).toThrow(/adopted record|traversal|limit/iu); }); + test('detaches earlier aliases before later proxy descriptor traps can mutate source records', () => { + const shared: Record = { safe: true }; + let inspections = 0; + const later = new Proxy( + { value: true }, + { + ownKeys(value) { + inspections += 1; + if (inspections === 2) { + Object.defineProperty(shared, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + } + return Reflect.ownKeys(value); + }, + }, + ); + const result = merge({}, { first: shared, second: later }); + + expect(has(result.first, '__proto__')).toBe(false); + const destination = {}; + expect(Object.assign(destination, result.first)).toBe(destination); + expect(Object.getPrototypeOf(destination)).toBe(Object.prototype); + expect(inspections).toBe(1); + }); + + test('keeps unpublished snapshots safe when a later proxy mutates the original alias', () => { + const shared: Record = { safe: true }; + const later = new Proxy( + { value: true }, + { + ownKeys(value) { + Object.defineProperty(shared, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + return Reflect.ownKeys(value); + }, + }, + ); + const result = merge({}, { first: shared, second: later }); + + expect(result.first).not.toBe(shared); + expect(has(result.first, '__proto__')).toBe(false); + expect(has(shared, '__proto__')).toBe(true); + }); + + test('snapshots two thousand aliases to a shared two-thousand-record graph only once', () => { + let inspections = 0; + const root: Record = new Proxy( + {}, + { + ownKeys(value) { + inspections += 1; + if (inspections > 4) { + throw new Error('Shared adopted graph was rescanned.'); + } + return Reflect.ownKeys(value); + }, + }, + ); + let current = root; + for (let index = 0; index < 1999; index += 1) { + current['next'] = {}; + current = current['next']; + } + const source: Record = {}; + for (let index = 0; index < 2000; index += 1) { + source[`alias-${index}`] = root; + } + const result = merge({}, source); + + expect(result['alias-0']).toBe(result['alias-1999']); + expect(result['alias-0']).not.toBe(root); + expect(inspections).toBeLessThanOrEqual(4); + }); + + test.each([ + { + name: 'ordinary function', + create: () => { + const safe = true; + return function value() { + return safe; + }; + }, + }, + { name: 'arrow function', create: () => () => true }, + { name: 'bound function', create: () => Object.prototype.hasOwnProperty.bind({}) }, + ])('preserves safe $name identity but rejects enumerable unsafe callable records', ({ create }) => { + const callable = create(); + + expect(merge({}, { safe: callable }).safe).toBe(callable); + Object.defineProperty(callable, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + expect(() => merge({}, { unsafe: callable })).toThrow(/safely sanitize|unsupported|callable/iu); + expect(has(callable, '__proto__')).toBe(true); + }); + + test('assign_single_source safely snapshots nested records and shared aliases', () => { + const unsafe = JSON.parse('{"__proto__":{"polluted":true},"safe":true}'); + const inherited = { inherited: true }; + const target = Object.create(inherited); + const result = assign_single_source(target, { first: unsafe, second: unsafe }); + + expect(result).toBe(target); + expect(Object.getPrototypeOf(result)).toBe(inherited); + expect(result.first).toBe(result.second); + expect(result.first).toEqual({ safe: true }); + expect(has(unsafe, '__proto__')).toBe(true); + }); + + test('assign_single_source rejects unsafe callable records without invoking them', () => { + let calls = 0; + const unsafe = () => { + calls += 1; + }; + Object.defineProperty(unsafe, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + + expect(() => assign_single_source({}, { unsafe })).toThrow(/safely sanitize|unsupported|callable/iu); + expect(calls).toBe(0); + }); + + test('preserves transitively frozen safe records without invoking their accessors', () => { + const child = Object.freeze({ value: true }); + const frozen = Object.freeze({ child }); + + expect(merge({}, { nested: frozen }).nested).toBe(frozen); + }); + test.each(['__proto__', 'constructor', 'prototype'])( 'merge ignores unsafe scalar key %s when inherited property names are allowed', (key) => { From 66892a9ee545e17eefc2398cb33814f939286261 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 00:39:36 +0000 Subject: [PATCH 5/6] fix(qs): validate source graphs before mutating merge targets --- src/internal/qs/utils.ts | 171 ++++++++++++++++++++++++++++----- tests/qs/utils.test.ts | 202 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+), 24 deletions(-) diff --git a/src/internal/qs/utils.ts b/src/internal/qs/utils.ts index 4cdac1f5d3..ed4c27bcfa 100644 --- a/src/internal/qs/utils.ts +++ b/src/internal/qs/utils.ts @@ -23,10 +23,15 @@ interface AdoptedRecord { ordinary: boolean; array: boolean; detached: boolean; + unsafe: boolean; + extensible: boolean; } interface MergeState { adopted: { target: object; key: PropertyKey }[]; + preparedTargets: WeakMap>; + preparedSources: WeakMap>; + inspectedSources: number; } const maxAdoptedRecords = 10_000; @@ -72,6 +77,7 @@ function sanitizeAdoptions(state: MergeState): void { const descriptors = Object.getOwnPropertyDescriptors(value); const prototype = Object.getPrototypeOf(value); const array = isArray(value); + const extensible = Object.isExtensible(value); const ordinary = typeof value !== 'function' && (array ? prototype === Array.prototype : prototype === Object.prototype || prototype === null); @@ -82,7 +88,9 @@ function sanitizeAdoptions(state: MergeState): void { parents: new Set(), ordinary, array, - detached: ordinary && !Object.isFrozen(value), + detached: ordinary && (extensible || !Object.isFrozen(value)), + unsafe: false, + extensible, }; records.set(value, record); visited.push(record); @@ -107,7 +115,13 @@ function sanitizeAdoptions(state: MergeState): void { if (isUnsafePropertyKey(key)) { if (record.ordinary) { record.detached = true; - } else if (descriptor.enumerable) { + record.unsafe = true; + } else if ( + typeof record.value !== 'function' || + key !== 'prototype' || + descriptor.enumerable || + descriptor.configurable + ) { throw new TypeError('Cannot safely sanitize an adopted callable or unsupported prototype.'); } continue; @@ -124,11 +138,24 @@ function sanitizeAdoptions(state: MergeState): void { } } - for (const record of detached) { + const unsafe = visited.filter((record) => record.unsafe); + for (const record of unsafe) { for (const parent of record.parents) { if (!parent.ordinary) { throw new TypeError('Cannot safely sanitize an adopted callable or unsupported prototype.'); } + if (!parent.unsafe) { + parent.unsafe = true; + unsafe.push(parent); + } + } + } + + for (const record of detached) { + for (const parent of record.parents) { + if (!parent.ordinary) { + continue; + } if (!parent.detached) { parent.detached = true; detached.push(parent); @@ -160,6 +187,102 @@ function sanitizeAdoptions(state: MergeState): void { Object.defineProperty(copies.get(target) ?? target, key, { ...descriptor, value }); } } + + for (const record of detached) { + if (!record.extensible) { + Object.preventExtensions(copies.get(record.value)); + } + } +} + +function readPreparedTarget(state: MergeState, target: any, key: PropertyKey): any { + const prepared = state.preparedTargets.get(target); + if (prepared?.has(key)) { + const value = prepared.get(key); + prepared.delete(key); + return value; + } + return target[key]; +} + +function previewTarget(state: MergeState, target: object, key: PropertyKey): any { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor && 'value' in descriptor) { + return descriptor.value; + } + + const value = Reflect.get(target, key, target); + let prepared = state.preparedTargets.get(target); + if (!prepared) { + prepared = new Map(); + state.preparedTargets.set(target, prepared); + } + prepared.set(key, value); + return value; +} + +function prepareMergeSource(target: any, source: any, state: MergeState, assign = false): any { + if (!source || typeof source !== 'object' || !target || typeof target !== 'object') { + return source; + } + + let preparedTargets = state.preparedSources.get(source); + if (!preparedTargets) { + preparedTargets = new WeakMap(); + state.preparedSources.set(source, preparedTargets); + } + const existing = preparedTargets.get(target); + if (existing) { + return existing; + } + state.inspectedSources += 1; + if (state.inspectedSources > maxAdoptedRecords) { + throw new Error('Adopted record traversal exceeds the supported safety limit.'); + } + + const sourceIsArray = isArray(source); + const prepared: Record = sourceIsArray ? [] : Object.create(null); + preparedTargets.set(target, prepared); + + if (isArray(target) && sourceIsArray && !assign) { + const sourceLength = source.length; + (prepared as any[]).length = sourceLength; + for (let index = 0; index < sourceLength; index += 1) { + if (!(index in source)) { + continue; + } + const value = source[index]; + if (has(target, index)) { + const targetValue = previewTarget(state, target, index); + prepared[index] = + targetValue && typeof targetValue === 'object' && value && typeof value === 'object' + ? prepareMergeSource(targetValue, value, state) + : value; + } else { + prepared[index] = value; + } + } + return prepared; + } + + for (const key of Object.keys(source)) { + if (isUnsafePropertyKey(key)) { + continue; + } + const value = source[key]; + prepared[key] = + !assign && has(target, key) && value && typeof value === 'object' + ? prepareMergeSource(previewTarget(state, target, key), value, state) + : value; + } + return prepared; +} + +function prepareSource(target: any, source: any, state: MergeState, assign = false): any { + const holder = { value: prepareMergeSource(target, source, state, assign) }; + rememberAdoption(state, holder, 'value', holder.value); + sanitizeAdoptions(state); + return holder.value; } const hex_table = /* @__PURE__ */ (() => { @@ -235,11 +358,7 @@ function mergeWithState( if (!target || typeof target !== 'object') { // oxlint-disable-next-line unicorn/prefer-spread -- concat intentionally preserves one-level flattening and sparse-array behavior. - const combined = [target].concat(source); - for (const [index, item] of combined.entries()) { - rememberAdoption(state, combined, index, item); - } - return combined; + return [target].concat(source); } let mergeTarget = target; @@ -254,20 +373,17 @@ function mergeWithState( if (i in source) { const item = source[i]; if (has(target, i)) { - const targetItem = target[i]; + const targetItem = readPreparedTarget(state, target, i); if (targetItem && typeof targetItem === 'object' && item && typeof item === 'object') { const merged = mergeWithState(targetItem, item, options, state); target[i] = merged; - rememberAdoption(state, target, i, merged); } else { const adopted = item; target.push(adopted); - rememberAdoption(state, target, target.length - 1, adopted); } } else { const adopted = item; target[i] = adopted; - rememberAdoption(state, target, i, adopted); } } } @@ -279,9 +395,10 @@ function mergeWithState( continue; } const value = source[key]; - const adopted = has(mergeTarget, key) ? mergeWithState(mergeTarget[key], value, options, state) : value; + const adopted = has(mergeTarget, key) + ? mergeWithState(readPreparedTarget(state, mergeTarget, key), value, options, state) + : value; mergeTarget[key] = adopted; - rememberAdoption(state, mergeTarget, key, adopted); } return mergeTarget; } @@ -291,23 +408,29 @@ export function merge( source: any, options: { plainObjects?: boolean; allowPrototypes?: boolean } = {}, ) { - const state: MergeState = { adopted: [] }; - const result = mergeWithState(target, source, options, state); - sanitizeAdoptions(state); - return result; + const state: MergeState = { + adopted: [], + preparedTargets: new WeakMap(), + preparedSources: new WeakMap(), + inspectedSources: 0, + }; + return mergeWithState(target, prepareSource(target, source, state), options, state); } export function assign_single_source(target: any, source: any) { - const state: MergeState = { adopted: [] }; - for (const key of Object.keys(source)) { + const state: MergeState = { + adopted: [], + preparedTargets: new WeakMap(), + preparedSources: new WeakMap(), + inspectedSources: 0, + }; + const prepared = prepareSource(target, source, state, true); + for (const key of Object.keys(prepared)) { if (isUnsafePropertyKey(key)) { continue; } - const value = source[key]; - target[key] = value; - rememberAdoption(state, target, key, value); + target[key] = prepared[key]; } - sanitizeAdoptions(state); return target; } diff --git a/tests/qs/utils.test.ts b/tests/qs/utils.test.ts index b225e237e2..79533f8cdf 100644 --- a/tests/qs/utils.test.ts +++ b/tests/qs/utils.test.ts @@ -86,6 +86,18 @@ test('assign()', () => { }); describe('prototype-pollution safety', () => { + const graphOperations = [ + { + name: 'merge', + apply: (target: Record, source: Record) => merge(target, source), + }, + { + name: 'assign_single_source', + apply: (target: Record, source: Record) => + assign_single_source(target, source), + }, + ]; + test.each([ { name: 'merge', @@ -266,6 +278,8 @@ describe('prototype-pollution safety', () => { create: () => new (class { #value = true; + child = { safe: true }; + getValue() { return this.#value; } @@ -444,6 +458,194 @@ describe('prototype-pollution safety', () => { expect(calls).toBe(0); }); + test.each(graphOperations)( + '$name rejects unsupported unsafe keys even when a proxy changes their enumerability', + ({ apply }) => { + const unsupported = Object.create({ inherited: true }) as Record; + Object.defineProperty(unsupported, '__proto__', { + configurable: true, + enumerable: false, + value: { polluted: true }, + }); + const unstable = new Proxy(unsupported, { + getPrototypeOf(value) { + Object.defineProperty(value, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + return Reflect.getPrototypeOf(value); + }, + }); + const target = { original: true }; + + expect(() => apply(target, { value: unstable })).toThrow(/safely sanitize|unsupported|callable/iu); + expect(target).toEqual({ original: true }); + }, + ); + + test.each(graphOperations)( + '$name leaves the caller target untouched when sanitation rejects', + ({ apply }) => { + const unsafe = new Date('2026-08-18T00:00:00.000Z'); + Object.defineProperty(unsafe, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + const target = { original: true }; + + expect(() => apply(target, { first: 'must not commit', unsafe })).toThrow( + /safely sanitize|unsupported|callable/iu, + ); + expect(target).toEqual({ original: true }); + }, + ); + + test.each(graphOperations)( + '$name snapshots source getters exactly once before committing any target property', + ({ apply }) => { + const unsafe = new Date('2026-08-18T00:00:00.000Z'); + Object.defineProperty(unsafe, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + let getterCalls = 0; + const source = { + first: 'must not commit', + get unsafe() { + getterCalls += 1; + return unsafe; + }, + }; + const target = { original: true }; + + expect(() => apply(target, source)).toThrow(/safely sanitize|unsupported|callable/iu); + expect(getterCalls).toBe(1); + expect(target).toEqual({ original: true }); + }, + ); + + test('merge snapshots recursive collision getters before mutating the caller target', () => { + const unsafe = new Date('2026-08-18T00:00:00.000Z'); + Object.defineProperty(unsafe, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + let getterCalls = 0; + const child = { existing: true }; + const target = { child }; + const source = { + first: 'must not commit', + child: { + get unsafe() { + getterCalls += 1; + return unsafe; + }, + }, + }; + + expect(() => merge(target, source)).toThrow(/safely sanitize|unsupported|callable/iu); + expect(getterCalls).toBe(1); + expect(Object.keys(target)).toEqual(['child']); + expect(target.child).toBe(child); + expect(child).toEqual({ existing: true }); + }); + + test.each(graphOperations)( + '$name leaves the caller target untouched when a descriptor trap fails', + ({ apply }) => { + const unsafe = new Proxy( + {}, + { + ownKeys() { + throw new Error('descriptor inspection failed'); + }, + }, + ); + const target = { original: true }; + + expect(() => apply(target, { first: 'must not commit', unsafe })).toThrow( + 'descriptor inspection failed', + ); + expect(target).toEqual({ original: true }); + }, + ); + + test.each(graphOperations)( + '$name leaves the caller target untouched when the traversal budget is exceeded', + ({ apply }) => { + const oversized: Record = {}; + for (let index = 0; index <= 10_000; index += 1) { + oversized[`value-${index}`] = index; + } + const target = { original: true }; + + expect(() => apply(target, { first: 'must not commit', oversized })).toThrow( + /adopted record|traversal|limit/iu, + ); + expect(target).toEqual({ original: true }); + }, + ); + + test.each(graphOperations)('$name preserves safe inherited instances with mutable fields', ({ apply }) => { + const instance = Object.assign( + Object.create({ + getValue() { + return true; + }, + }), + { child: { safe: true } }, + ); + const date = Object.assign(new Date('2026-08-18T00:00:00.000Z'), { child: { safe: true } }); + const callable = Object.assign(() => true, { child: { safe: true } }); + const result = apply({}, { instance, date, callable }); + + expect(result.instance).toBe(instance); + expect(result.instance.getValue()).toBe(true); + expect(result.date).toBe(date); + expect(result.date.toISOString()).toBe('2026-08-18T00:00:00.000Z'); + expect(result.callable).toBe(callable); + expect(result.callable()).toBe(true); + expect(result.instance.child).toBe(instance.child); + expect(result.date.child).toBe(date.child); + expect(result.callable.child).toBe(callable.child); + }); + + test('merge preserves caller-owned nested target records and arrays', () => { + const nested = { existing: true }; + const arrayEntry = { existing: true }; + const array = [arrayEntry]; + const target = { nested, array }; + + expect(merge(target, { nested: { added: true }, array: [{ added: true }] })).toBe(target); + expect(target.nested).toBe(nested); + expect(target.nested).toEqual({ existing: true, added: true }); + expect(target.array).toBe(array); + expect(target.array[0]).toBe(arrayEntry); + expect(target.array[0]).toEqual({ existing: true, added: true }); + }); + + test.each(graphOperations)('$name preserves sealed, non-extensible, and frozen integrity', ({ apply }) => { + const sealed = Object.seal({ value: true }); + const nonExtensible = Object.preventExtensions({ value: true }); + const unsafe = JSON.parse('{"__proto__":{"polluted":true},"safe":true}') as Record; + const frozen = Object.freeze({ child: unsafe }); + const result = apply({}, { sealed, nonExtensible, frozen }); + + expect(result.sealed).not.toBe(sealed); + expect(Object.isSealed(result.sealed)).toBe(true); + expect(Object.getOwnPropertyDescriptor(result.sealed, 'value')?.writable).toBe(true); + expect(result.nonExtensible).not.toBe(nonExtensible); + expect(Object.isExtensible(result.nonExtensible)).toBe(false); + expect(Object.isSealed(result.nonExtensible)).toBe(false); + expect(result.frozen).not.toBe(frozen); + expect(Object.isFrozen(result.frozen)).toBe(true); + expect(has(result.frozen.child, '__proto__')).toBe(false); + }); + test('preserves transitively frozen safe records without invoking their accessors', () => { const child = Object.freeze({ value: true }); const frozen = Object.freeze({ child }); From 91913b2107ea45db82aa9c1f53a2d848a8ecb789 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 19 Aug 2026 00:53:19 +0000 Subject: [PATCH 6/6] fix(qs): bound descriptor scans and stabilize retained keys --- src/internal/qs/utils.ts | 88 ++++++++++++++++++++++++------ tests/qs/utils.test.ts | 115 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 18 deletions(-) diff --git a/src/internal/qs/utils.ts b/src/internal/qs/utils.ts index ed4c27bcfa..1d55b3820e 100644 --- a/src/internal/qs/utils.ts +++ b/src/internal/qs/utils.ts @@ -18,6 +18,7 @@ function isUnsafePropertyKey(key: unknown): boolean { interface AdoptedRecord { value: object; descriptors: PropertyDescriptorMap; + keys: PropertyKey[]; prototype: object | null; parents: Set; ordinary: boolean; @@ -32,10 +33,21 @@ interface MergeState { preparedTargets: WeakMap>; preparedSources: WeakMap>; inspectedSources: number; + inspectedSourceProperties: number; } const maxAdoptedRecords = 10_000; +function isIntrinsicFunctionPrototype( + value: object, + key: PropertyKey, + descriptor: PropertyDescriptor, +): boolean { + return ( + typeof value === 'function' && key === 'prototype' && !descriptor.enumerable && !descriptor.configurable + ); +} + function isObjectLike(value: unknown): value is object { return value !== null && (typeof value === 'object' || typeof value === 'function'); } @@ -74,7 +86,18 @@ function sanitizeAdoptions(state: MergeState): void { throw new Error('Adopted record traversal exceeds the supported safety limit.'); } - const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(value); + if (keys.length > maxAdoptedRecords - inspectedProperties) { + throw new Error('Adopted record traversal exceeds the supported safety limit.'); + } + inspectedProperties += keys.length; + const descriptors: PropertyDescriptorMap = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor) { + Reflect.set(descriptors, key, descriptor); + } + } const prototype = Object.getPrototypeOf(value); const array = isArray(value); const extensible = Object.isExtensible(value); @@ -84,6 +107,7 @@ function sanitizeAdoptions(state: MergeState): void { const record: AdoptedRecord = { value, descriptors, + keys, prototype, parents: new Set(), ordinary, @@ -106,22 +130,16 @@ function sanitizeAdoptions(state: MergeState): void { const detached: AdoptedRecord[] = []; for (const record of visited) { - for (const key of Reflect.ownKeys(record.descriptors)) { - inspectedProperties += 1; - if (inspectedProperties > maxAdoptedRecords) { - throw new Error('Adopted record traversal exceeds the supported safety limit.'); + for (const key of record.keys) { + const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor | undefined; + if (!descriptor) { + continue; } - const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; if (isUnsafePropertyKey(key)) { if (record.ordinary) { record.detached = true; record.unsafe = true; - } else if ( - typeof record.value !== 'function' || - key !== 'prototype' || - descriptor.enumerable || - descriptor.configurable - ) { + } else if (!isIntrinsicFunctionPrototype(record.value, key, descriptor)) { throw new TypeError('Cannot safely sanitize an adopted callable or unsupported prototype.'); } continue; @@ -138,6 +156,19 @@ function sanitizeAdoptions(state: MergeState): void { } } + for (let index = visited.length - 1; index >= 0; index -= 1) { + const record = visited[index]!; + if (record.ordinary) { + continue; + } + for (const key of ['__proto__', 'constructor', 'prototype'] as const) { + const descriptor = Object.getOwnPropertyDescriptor(record.value, key); + if (descriptor && !isIntrinsicFunctionPrototype(record.value, key, descriptor)) { + throw new TypeError('Cannot safely sanitize an adopted callable or unsupported prototype.'); + } + } + } + const unsafe = visited.filter((record) => record.unsafe); for (const record of unsafe) { for (const parent of record.parents) { @@ -169,11 +200,14 @@ function sanitizeAdoptions(state: MergeState): void { } for (const record of detached) { const copy = copies.get(record.value); - for (const key of Reflect.ownKeys(record.descriptors)) { + for (const key of record.keys) { if (isUnsafePropertyKey(key)) { continue; } - const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor; + const descriptor = Reflect.get(record.descriptors, key) as PropertyDescriptor | undefined; + if (!descriptor) { + continue; + } if ('value' in descriptor && isObjectLike(descriptor.value) && copies.has(descriptor.value)) { descriptor.value = copies.get(descriptor.value); } @@ -240,6 +274,11 @@ function prepareMergeSource(target: any, source: any, state: MergeState, assign throw new Error('Adopted record traversal exceeds the supported safety limit.'); } + const sourceKeys = Reflect.ownKeys(source); + if (sourceKeys.length > maxAdoptedRecords - state.inspectedSourceProperties) { + throw new Error('Adopted record traversal exceeds the supported safety limit.'); + } + state.inspectedSourceProperties += sourceKeys.length; const sourceIsArray = isArray(source); const prepared: Record = sourceIsArray ? [] : Object.create(null); preparedTargets.set(target, prepared); @@ -265,7 +304,13 @@ function prepareMergeSource(target: any, source: any, state: MergeState, assign return prepared; } - for (const key of Object.keys(source)) { + const enumerableKeys: string[] = []; + for (const key of sourceKeys) { + if (typeof key === 'string' && Object.getOwnPropertyDescriptor(source, key)?.enumerable) { + enumerableKeys.push(key); + } + } + for (const key of enumerableKeys) { if (isUnsafePropertyKey(key)) { continue; } @@ -343,11 +388,16 @@ function mergeWithState( if (isArray(target)) { target.push(source); } else if (target && typeof target === 'object') { + const propertyKey = + typeof source === 'string' || typeof source === 'symbol' + ? source + : Reflect.ownKeys({ [source]: true })[0]!; if ( - !isUnsafePropertyKey(source) && - ((options && (options.plainObjects || options.allowPrototypes)) || !has(Object.prototype, source)) + !isUnsafePropertyKey(propertyKey) && + ((options && (options.plainObjects || options.allowPrototypes)) || + !has(Object.prototype, propertyKey)) ) { - target[source] = true; + target[propertyKey] = true; } } else { return [target, source]; @@ -413,6 +463,7 @@ export function merge( preparedTargets: new WeakMap(), preparedSources: new WeakMap(), inspectedSources: 0, + inspectedSourceProperties: 0, }; return mergeWithState(target, prepareSource(target, source, state), options, state); } @@ -423,6 +474,7 @@ export function assign_single_source(target: any, source: any) { preparedTargets: new WeakMap(), preparedSources: new WeakMap(), inspectedSources: 0, + inspectedSourceProperties: 0, }; const prepared = prepareSource(target, source, state, true); for (const key of Object.keys(prepared)) { diff --git a/tests/qs/utils.test.ts b/tests/qs/utils.test.ts index 79533f8cdf..1d5dd72eed 100644 --- a/tests/qs/utils.test.ts +++ b/tests/qs/utils.test.ts @@ -646,6 +646,121 @@ describe('prototype-pollution safety', () => { expect(has(result.frozen.child, '__proto__')).toBe(false); }); + test.each(graphOperations)( + '$name rejects retained inherited parents polluted by a later child Proxy trap', + ({ apply }) => { + const parent = Object.create({ inherited: true }) as Record; + let inspections = 0; + parent['child'] = new Proxy( + { safe: true }, + { + ownKeys(value) { + inspections += 1; + Object.defineProperty(parent, '__proto__', { + configurable: true, + enumerable: true, + value: { polluted: true }, + }); + return Reflect.ownKeys(value); + }, + }, + ); + const target = { original: true }; + + expect(() => apply(target, { first: 'must not commit', parent })).toThrow( + /safely sanitize|unsupported|callable/iu, + ); + expect(inspections).toBe(1); + expect(target).toEqual({ original: true }); + }, + ); + + test.each(graphOperations)( + '$name rejects an oversized root or adopted record before inspecting any descriptors', + ({ apply }) => { + for (const position of ['root', 'adopted']) { + const wide: Record = {}; + for (let index = 0; index <= 10_000; index += 1) { + wide[`value-${index}`] = index; + } + let descriptorCalls = 0; + let keyEnumerations = 0; + const hostile = new Proxy(wide, { + ownKeys(value) { + keyEnumerations += 1; + return Reflect.ownKeys(value); + }, + getOwnPropertyDescriptor(value, key) { + descriptorCalls += 1; + return Object.getOwnPropertyDescriptor(value, key); + }, + }); + const target = { original: true }; + const source = position === 'root' ? hostile : { first: 'must not commit', hostile }; + + expect(() => apply(target, source)).toThrow(/adopted record|traversal|limit/iu); + expect(keyEnumerations).toBe(1); + expect(descriptorCalls).toBe(0); + expect(target).toEqual({ original: true }); + } + }, + ); + + test('merge coerces a stateful callable scalar key exactly once before default lookup', () => { + let coercions = 0; + const callable = () => coercions >= 0; + Object.defineProperty(callable, Symbol.toPrimitive, { + value() { + coercions += 1; + return coercions === 1 ? 'safe' : 'constructor'; + }, + }); + const target: Record = {}; + + expect(merge(target, callable)).toBe(target); + expect(coercions).toBe(1); + expect(target).toEqual({ safe: true }); + expect(has(target, 'constructor')).toBe(false); + expect(has(target, 'prototype')).toBe(false); + }); + + test.each(['__proto__', 'constructor', 'prototype'])( + 'merge rejects coerced unsafe callable key %s when inherited names are allowed', + (unsafeKey) => { + let coercions = 0; + const callable = () => coercions >= 0; + Object.defineProperty(callable, Symbol.toPrimitive, { + value() { + coercions += 1; + return unsafeKey; + }, + }); + const target: Record = {}; + + expect(merge(target, callable, { allowPrototypes: true })).toBe(target); + expect(coercions).toBe(1); + expect(Object.getPrototypeOf(target)).toBe(Object.prototype); + expect(has(target, unsafeKey)).toBe(false); + }, + ); + + test('merge preserves a callable scalar key whose primitive value is a symbol', () => { + const safe = Symbol('safe'); + let coercions = 0; + const callable = () => coercions >= 0; + Object.defineProperty(callable, Symbol.toPrimitive, { + value() { + coercions += 1; + return safe; + }, + }); + const target: Record = {}; + + expect(merge(target, callable, { allowPrototypes: true })).toBe(target); + expect(coercions).toBe(1); + expect(target[safe]).toBe(true); + }); + test('preserves transitively frozen safe records without invoking their accessors', () => { const child = Object.freeze({ value: true }); const frozen = Object.freeze({ child });