From 987ad5543eae6ede7d20e49ebe611ca69eab46e7 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 15 Sep 2026 09:21:18 +0700 Subject: [PATCH 1/2] fix: stop path walks resolving through the prototype chain Converge every remaining path walk over untrusted segments onto the shared getOwn/setOwn helpers, matching BracketNotationSerializer (#2024): the RPC and OpenAPI JSON deserializers, getRouter, getRouterContract, JsonSchemaCoercer, and get() in @orpc/shared. getRouter and getRouterContract previously resolved segments like __proto__ and constructor through the prototype chain; they now return undefined. The other sites are idiom convergence: they were own-only already, but only via the previous iteration's Object.hasOwn guard. --- .../client/src/rpc-json-serializer.test.ts | 28 +++++++++++++++++++ packages/client/src/rpc-json-serializer.ts | 10 +++---- packages/contract/src/router-utils.test.ts | 8 ++++++ packages/contract/src/router-utils.ts | 4 +-- packages/json-schema/src/coercer.ts | 11 ++++---- .../src/openapi-json-serializer.test.ts | 24 ++++++++++++++++ .../openapi/src/openapi-json-serializer.ts | 6 ++-- packages/server/src/router-utils.test.ts | 8 ++++++ packages/server/src/router-utils.ts | 4 +-- packages/shared/src/object.ts | 6 ++-- 10 files changed, 88 insertions(+), 21 deletions(-) diff --git a/packages/client/src/rpc-json-serializer.test.ts b/packages/client/src/rpc-json-serializer.test.ts index 526353d4b..c32ad8b21 100644 --- a/packages/client/src/rpc-json-serializer.test.ts +++ b/packages/client/src/rpc-json-serializer.test.ts @@ -453,5 +453,33 @@ describe('rpcJsonSerializer: security', () => { expect(Object.hasOwn(restored, '__proto__')).toBe(true) expect(restored.__proto__).toBeInstanceOf(Date) }) + + it('walks an own constructor/prototype chain without reaching the real Object.prototype', () => { + const blob = new Blob(['x']) + + const restored = serializer.deserialize({ + json: JSON.parse('{"constructor": {"prototype": {"when": "2023-01-01T00:00:00.000Z", "polluted": null}}}'), + meta: [['date', 'constructor', 'prototype', 'when']], + maps: [['constructor', 'prototype', 'polluted']], + blobs: [blob], + }) as any + + expect(restored.constructor.prototype.when).toBeInstanceOf(Date) + expect(restored.constructor.prototype.polluted).toBe(blob) + }) + + it('restores a blob stored directly under an own __proto__ key', () => { + const blob = new Blob(['x']) + + const restored = serializer.deserialize({ + json: JSON.parse('{"__proto__": null}'), + maps: [['__proto__']], + blobs: [blob], + }) as any + + expect(Object.hasOwn(restored, '__proto__')).toBe(true) + expect(restored.__proto__).toBe(blob) + expect(Object.getPrototypeOf(restored)).toBe(Object.prototype) + }) /* eslint-enable no-proto, no-restricted-properties */ }) diff --git a/packages/client/src/rpc-json-serializer.ts b/packages/client/src/rpc-json-serializer.ts index 7cf687802..4088efc20 100644 --- a/packages/client/src/rpc-json-serializer.ts +++ b/packages/client/src/rpc-json-serializer.ts @@ -1,5 +1,5 @@ import type { Segment } from '@orpc/shared' -import { isPlainObject, NullProtoObj } from '@orpc/shared' +import { getOwn, isPlainObject, NullProtoObj, setOwn } from '@orpc/shared' export type RPCJsonSerializationMeta = [type: string, ...path: Segment[]] export type RPCJsonSerialization @@ -383,7 +383,7 @@ export class RPCJsonSerializer { let preSegment: string | number = 'data' for (let j = 0; j < segments.length; j++) { - currentRef = currentRef[preSegment] + currentRef = getOwn(currentRef, preSegment) preSegment = segments[j]! if (!Object.hasOwn(currentRef, preSegment)) { @@ -391,7 +391,7 @@ export class RPCJsonSerializer { } } - currentRef[preSegment] = serialized.blobs[i] + setOwn(currentRef, preSegment, serialized.blobs[i]) } } @@ -403,7 +403,7 @@ export class RPCJsonSerializer { let preSegment: string | number = 'data' for (let i = 1; i < item.length; i++) { - currentRef = currentRef[preSegment] + currentRef = getOwn(currentRef, preSegment) preSegment = item[i]! if (!Object.hasOwn(currentRef, preSegment)) { @@ -411,7 +411,7 @@ export class RPCJsonSerializer { } } - currentRef[preSegment] = this.handlers[type]!.deserialize(currentRef[preSegment]) + setOwn(currentRef, preSegment, this.handlers[type]!.deserialize(getOwn(currentRef, preSegment))) } } diff --git a/packages/contract/src/router-utils.test.ts b/packages/contract/src/router-utils.test.ts index 2dc069c27..dfeac1f71 100644 --- a/packages/contract/src/router-utils.test.ts +++ b/packages/contract/src/router-utils.test.ts @@ -195,6 +195,14 @@ describe('getRouterContract', () => { expect(getRouterContract('invalid' as any, [])).toBeUndefined() expect(getRouterContract('invalid' as any, ['invalid'])).toBeUndefined() }) + + it('never resolves a segment through the prototype chain', () => { + expect(getRouterContract(router, ['__proto__'])).toBeUndefined() + expect(getRouterContract(router, ['constructor'])).toBeUndefined() + expect(getRouterContract(router, ['constructor', 'prototype'])).toBeUndefined() + expect(getRouterContract(router, ['toString'])).toBeUndefined() + expect(getRouterContract(router, ['nested', '__proto__', 'ping'])).toBeUndefined() + }) }) describe('getProcedureContractOrThrow', () => { diff --git a/packages/contract/src/router-utils.ts b/packages/contract/src/router-utils.ts index 4a9e6a4ee..2dbba1ccb 100644 --- a/packages/contract/src/router-utils.ts +++ b/packages/contract/src/router-utils.ts @@ -3,7 +3,7 @@ import type { MergedErrorMap } from './error-utils' import type { AnyMetaPlugin, Meta } from './meta' import type { AnyProcedureContract } from './procedure' import type { RouterContract } from './router' -import { isTypescriptObject } from '@orpc/shared' +import { getOwn, isTypescriptObject } from '@orpc/shared' import { mergeErrorMap } from './error-utils' import { resolveMetaPlugins } from './meta-utils' import { ProcedureContract } from './procedure' @@ -72,7 +72,7 @@ export function getRouterContract(router: RouterContract, path: readonly string[ return undefined } - current = current[segment] + current = getOwn(current, segment) } if (!isTypescriptObject(current)) { diff --git a/packages/json-schema/src/coercer.ts b/packages/json-schema/src/coercer.ts index 8cd22eaa3..dacbd8f76 100644 --- a/packages/json-schema/src/coercer.ts +++ b/packages/json-schema/src/coercer.ts @@ -1,5 +1,5 @@ import type { JsonSchema } from './types' -import { get, isPlainObject, toArray, tryOrUndefined } from '@orpc/shared' +import { get, getOwn, isPlainObject, setOwn, toArray, tryOrUndefined } from '@orpc/shared' import { decodeJsonPointerSegment } from './ref-utils' import { JsonSchemaXNativeType } from './types' @@ -204,7 +204,7 @@ export class JsonSchemaCoercer { if (isPlainObject(coerced)) { let shouldUseCoercedItems = false - // copy here so special keys like `__proto__` are kept as own properties + // copy so the input is never mutated const coercedItems = { ...coerced } const patternProperties = Object.entries(schema.patternProperties ?? {}) @@ -214,13 +214,12 @@ export class JsonSchemaCoercer { return pattern ? [[pattern, value] as const] : [] }) - const propertySchemas = schema.properties + const propertySchemas: Record = schema.properties ?? {} for (const key in coerced) { const value = coerced[key] - // `properties[key]` alone would resolve keys like `__proto__` to `Object.prototype` - const subSchema = (propertySchemas !== undefined && Object.hasOwn(propertySchemas, key) ? propertySchemas[key] : undefined) + const subSchema = getOwn(propertySchemas, key) ?? patternProperties.find(([pattern]) => pattern.test(key))?.[1] ?? schema.additionalProperties @@ -230,7 +229,7 @@ export class JsonSchemaCoercer { } else { const [subSatisfied, subCoerced] = this.coerceInternal(rootSchema, subSchema, value) - coercedItems[key] = subCoerced + setOwn(coercedItems, key, subCoerced) satisfied = minSatisfaction(satisfied, subSatisfied) diff --git a/packages/openapi/src/openapi-json-serializer.test.ts b/packages/openapi/src/openapi-json-serializer.test.ts index 567d9177a..fe665ba86 100644 --- a/packages/openapi/src/openapi-json-serializer.test.ts +++ b/packages/openapi/src/openapi-json-serializer.test.ts @@ -296,6 +296,30 @@ describe('openAPIJsonSerializer', () => { expect(Object.hasOwn(result, '__proto__')).toBe(true) expect(result.__proto__.file).toBe(blob) }) + + it('restores a blob stored directly under an own __proto__ key', () => { + const blob = new Blob(['x']) + const result = serializer.deserialize({ + json: JSON.parse('{"__proto__": null}'), + maps: [['__proto__']], + blobs: [blob], + }) as any + + expect(Object.hasOwn(result, '__proto__')).toBe(true) + expect(result.__proto__).toBe(blob) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + }) + + it('walks an own constructor/prototype chain without reaching the real Object.prototype', () => { + const blob = new Blob(['x']) + const result = serializer.deserialize({ + json: JSON.parse('{"constructor": {"prototype": {"polluted": null}}}'), + maps: [['constructor', 'prototype', 'polluted']], + blobs: [blob], + }) as any + + expect(result.constructor.prototype.polluted).toBe(blob) + }) /* eslint-enable no-proto, no-restricted-properties */ }) }) diff --git a/packages/openapi/src/openapi-json-serializer.ts b/packages/openapi/src/openapi-json-serializer.ts index 6fec8cda7..2be22b80e 100644 --- a/packages/openapi/src/openapi-json-serializer.ts +++ b/packages/openapi/src/openapi-json-serializer.ts @@ -1,5 +1,5 @@ import type { Segment } from '@orpc/shared' -import { isPlainObject, NullProtoObj } from '@orpc/shared' +import { getOwn, isPlainObject, NullProtoObj, setOwn } from '@orpc/shared' export type OpenAPIJsonSerialization = | { json: unknown, maps?: undefined, blobs?: undefined } @@ -319,7 +319,7 @@ export class OpenAPIJsonSerializer { let preSegment: string | number = 'data' for (let j = 0; j < segments.length; j++) { - currentRef = currentRef[preSegment] + currentRef = getOwn(currentRef, preSegment) preSegment = segments[j]! if (!Object.hasOwn(currentRef, preSegment)) { @@ -327,7 +327,7 @@ export class OpenAPIJsonSerializer { } } - currentRef[preSegment] = serialized.blobs[i] + setOwn(currentRef, preSegment, serialized.blobs[i]) } } diff --git a/packages/server/src/router-utils.test.ts b/packages/server/src/router-utils.test.ts index b39742491..18ab9d8a7 100644 --- a/packages/server/src/router-utils.test.ts +++ b/packages/server/src/router-utils.test.ts @@ -337,6 +337,14 @@ describe('getRouter', () => { expect(getRouter('invalid' as any, [])).toBeUndefined() expect(getRouter('invalid' as any, ['invalid'])).toBeUndefined() }) + + it('never resolves a segment through the prototype chain', () => { + expect(getRouter(router, ['__proto__'])).toBeUndefined() + expect(getRouter(router, ['constructor'])).toBeUndefined() + expect(getRouter(router, ['constructor', 'prototype'])).toBeUndefined() + expect(getRouter(router, ['toString'])).toBeUndefined() + expect(getRouter(router, ['nested', '__proto__', 'p1'])).toBeUndefined() + }) }) describe('walkProcedureContractsSync', () => { diff --git a/packages/server/src/router-utils.ts b/packages/server/src/router-utils.ts index d38ea9e57..b6521048f 100644 --- a/packages/server/src/router-utils.ts +++ b/packages/server/src/router-utils.ts @@ -6,7 +6,7 @@ import type { AnyMiddleware } from './middleware' import type { AnyProcedure, ProcedureConfig } from './procedure' import type { AnyRouter } from './router' import { mergeErrorMap, ProcedureContract, resolveMetaPlugins } from '@orpc/contract' -import { isTypescriptObject, omit } from '@orpc/shared' +import { getOwn, isTypescriptObject, omit } from '@orpc/shared' import { Lazy, unlazy } from './lazy' import { Procedure } from './procedure' import { getHiddenRouterContract } from './router-hidden' @@ -201,7 +201,7 @@ export function getRouter>( } if (!(current instanceof Lazy)) { - current = current[segment] + current = getOwn(current, segment) continue } diff --git a/packages/shared/src/object.ts b/packages/shared/src/object.ts index a1617ea79..909f35e91 100644 --- a/packages/shared/src/object.ts +++ b/packages/shared/src/object.ts @@ -81,11 +81,11 @@ export function get(object: unknown, path: readonly PropertyKey[]): unknown { let current: unknown = object for (const key of path) { - if (!isTypescriptObject(current) || !Object.hasOwn(current, key)) { + if (!isTypescriptObject(current)) { return undefined } - current = current[key] + current = getOwn(current, key) } return current @@ -103,7 +103,7 @@ export function set( for (let i = 0; i < path.length - 1; i++) { const key = path[i]! - const next = Object.hasOwn(current, key) ? (current as Record)[key] : undefined + const next = getOwn(current, key) if (!isTypescriptObject(next)) { const child = {} From 5dabce225f9fc7a69eb26b1b23a32703c95bd8a9 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 15 Sep 2026 09:43:59 +0700 Subject: [PATCH 2/2] refactor: keep the own-key guards where the walk already proves own-ness Revert getOwn/setOwn in the RPC and OpenAPI deserializers, getRouter, getRouterContract and the coercer write. The deserializer loops verify each segment with Object.hasOwn before reading it, so the extra check only costs, and router paths come from the router definition, not the request. Keep getOwn in get() and the coercer property lookup, which were hand-rolled copies of it. --- packages/client/src/rpc-json-serializer.ts | 10 +++++----- packages/contract/src/router-utils.test.ts | 8 -------- packages/contract/src/router-utils.ts | 4 ++-- packages/json-schema/src/coercer.ts | 6 +++--- packages/openapi/src/openapi-json-serializer.ts | 6 +++--- packages/server/src/router-utils.test.ts | 8 -------- packages/server/src/router-utils.ts | 4 ++-- 7 files changed, 15 insertions(+), 31 deletions(-) diff --git a/packages/client/src/rpc-json-serializer.ts b/packages/client/src/rpc-json-serializer.ts index 4088efc20..7cf687802 100644 --- a/packages/client/src/rpc-json-serializer.ts +++ b/packages/client/src/rpc-json-serializer.ts @@ -1,5 +1,5 @@ import type { Segment } from '@orpc/shared' -import { getOwn, isPlainObject, NullProtoObj, setOwn } from '@orpc/shared' +import { isPlainObject, NullProtoObj } from '@orpc/shared' export type RPCJsonSerializationMeta = [type: string, ...path: Segment[]] export type RPCJsonSerialization @@ -383,7 +383,7 @@ export class RPCJsonSerializer { let preSegment: string | number = 'data' for (let j = 0; j < segments.length; j++) { - currentRef = getOwn(currentRef, preSegment) + currentRef = currentRef[preSegment] preSegment = segments[j]! if (!Object.hasOwn(currentRef, preSegment)) { @@ -391,7 +391,7 @@ export class RPCJsonSerializer { } } - setOwn(currentRef, preSegment, serialized.blobs[i]) + currentRef[preSegment] = serialized.blobs[i] } } @@ -403,7 +403,7 @@ export class RPCJsonSerializer { let preSegment: string | number = 'data' for (let i = 1; i < item.length; i++) { - currentRef = getOwn(currentRef, preSegment) + currentRef = currentRef[preSegment] preSegment = item[i]! if (!Object.hasOwn(currentRef, preSegment)) { @@ -411,7 +411,7 @@ export class RPCJsonSerializer { } } - setOwn(currentRef, preSegment, this.handlers[type]!.deserialize(getOwn(currentRef, preSegment))) + currentRef[preSegment] = this.handlers[type]!.deserialize(currentRef[preSegment]) } } diff --git a/packages/contract/src/router-utils.test.ts b/packages/contract/src/router-utils.test.ts index dfeac1f71..2dc069c27 100644 --- a/packages/contract/src/router-utils.test.ts +++ b/packages/contract/src/router-utils.test.ts @@ -195,14 +195,6 @@ describe('getRouterContract', () => { expect(getRouterContract('invalid' as any, [])).toBeUndefined() expect(getRouterContract('invalid' as any, ['invalid'])).toBeUndefined() }) - - it('never resolves a segment through the prototype chain', () => { - expect(getRouterContract(router, ['__proto__'])).toBeUndefined() - expect(getRouterContract(router, ['constructor'])).toBeUndefined() - expect(getRouterContract(router, ['constructor', 'prototype'])).toBeUndefined() - expect(getRouterContract(router, ['toString'])).toBeUndefined() - expect(getRouterContract(router, ['nested', '__proto__', 'ping'])).toBeUndefined() - }) }) describe('getProcedureContractOrThrow', () => { diff --git a/packages/contract/src/router-utils.ts b/packages/contract/src/router-utils.ts index 2dbba1ccb..4a9e6a4ee 100644 --- a/packages/contract/src/router-utils.ts +++ b/packages/contract/src/router-utils.ts @@ -3,7 +3,7 @@ import type { MergedErrorMap } from './error-utils' import type { AnyMetaPlugin, Meta } from './meta' import type { AnyProcedureContract } from './procedure' import type { RouterContract } from './router' -import { getOwn, isTypescriptObject } from '@orpc/shared' +import { isTypescriptObject } from '@orpc/shared' import { mergeErrorMap } from './error-utils' import { resolveMetaPlugins } from './meta-utils' import { ProcedureContract } from './procedure' @@ -72,7 +72,7 @@ export function getRouterContract(router: RouterContract, path: readonly string[ return undefined } - current = getOwn(current, segment) + current = current[segment] } if (!isTypescriptObject(current)) { diff --git a/packages/json-schema/src/coercer.ts b/packages/json-schema/src/coercer.ts index dacbd8f76..fd424e72f 100644 --- a/packages/json-schema/src/coercer.ts +++ b/packages/json-schema/src/coercer.ts @@ -1,5 +1,5 @@ import type { JsonSchema } from './types' -import { get, getOwn, isPlainObject, setOwn, toArray, tryOrUndefined } from '@orpc/shared' +import { get, getOwn, isPlainObject, toArray, tryOrUndefined } from '@orpc/shared' import { decodeJsonPointerSegment } from './ref-utils' import { JsonSchemaXNativeType } from './types' @@ -204,7 +204,7 @@ export class JsonSchemaCoercer { if (isPlainObject(coerced)) { let shouldUseCoercedItems = false - // copy so the input is never mutated + // copy here so special keys like `__proto__` are kept as own properties const coercedItems = { ...coerced } const patternProperties = Object.entries(schema.patternProperties ?? {}) @@ -229,7 +229,7 @@ export class JsonSchemaCoercer { } else { const [subSatisfied, subCoerced] = this.coerceInternal(rootSchema, subSchema, value) - setOwn(coercedItems, key, subCoerced) + coercedItems[key] = subCoerced satisfied = minSatisfaction(satisfied, subSatisfied) diff --git a/packages/openapi/src/openapi-json-serializer.ts b/packages/openapi/src/openapi-json-serializer.ts index 2be22b80e..6fec8cda7 100644 --- a/packages/openapi/src/openapi-json-serializer.ts +++ b/packages/openapi/src/openapi-json-serializer.ts @@ -1,5 +1,5 @@ import type { Segment } from '@orpc/shared' -import { getOwn, isPlainObject, NullProtoObj, setOwn } from '@orpc/shared' +import { isPlainObject, NullProtoObj } from '@orpc/shared' export type OpenAPIJsonSerialization = | { json: unknown, maps?: undefined, blobs?: undefined } @@ -319,7 +319,7 @@ export class OpenAPIJsonSerializer { let preSegment: string | number = 'data' for (let j = 0; j < segments.length; j++) { - currentRef = getOwn(currentRef, preSegment) + currentRef = currentRef[preSegment] preSegment = segments[j]! if (!Object.hasOwn(currentRef, preSegment)) { @@ -327,7 +327,7 @@ export class OpenAPIJsonSerializer { } } - setOwn(currentRef, preSegment, serialized.blobs[i]) + currentRef[preSegment] = serialized.blobs[i] } } diff --git a/packages/server/src/router-utils.test.ts b/packages/server/src/router-utils.test.ts index 18ab9d8a7..b39742491 100644 --- a/packages/server/src/router-utils.test.ts +++ b/packages/server/src/router-utils.test.ts @@ -337,14 +337,6 @@ describe('getRouter', () => { expect(getRouter('invalid' as any, [])).toBeUndefined() expect(getRouter('invalid' as any, ['invalid'])).toBeUndefined() }) - - it('never resolves a segment through the prototype chain', () => { - expect(getRouter(router, ['__proto__'])).toBeUndefined() - expect(getRouter(router, ['constructor'])).toBeUndefined() - expect(getRouter(router, ['constructor', 'prototype'])).toBeUndefined() - expect(getRouter(router, ['toString'])).toBeUndefined() - expect(getRouter(router, ['nested', '__proto__', 'p1'])).toBeUndefined() - }) }) describe('walkProcedureContractsSync', () => { diff --git a/packages/server/src/router-utils.ts b/packages/server/src/router-utils.ts index b6521048f..d38ea9e57 100644 --- a/packages/server/src/router-utils.ts +++ b/packages/server/src/router-utils.ts @@ -6,7 +6,7 @@ import type { AnyMiddleware } from './middleware' import type { AnyProcedure, ProcedureConfig } from './procedure' import type { AnyRouter } from './router' import { mergeErrorMap, ProcedureContract, resolveMetaPlugins } from '@orpc/contract' -import { getOwn, isTypescriptObject, omit } from '@orpc/shared' +import { isTypescriptObject, omit } from '@orpc/shared' import { Lazy, unlazy } from './lazy' import { Procedure } from './procedure' import { getHiddenRouterContract } from './router-hidden' @@ -201,7 +201,7 @@ export function getRouter>( } if (!(current instanceof Lazy)) { - current = getOwn(current, segment) + current = current[segment] continue }