Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/zod/src/coercer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,42 @@ it('zodSmartCoercionPlugin ignore non-zod schemas', async () => {
const v = await import('valibot')
expect(coerce(v.object({}), val)).toBe(val)
})

it('zodSmartCoercionPlugin prevents prototype injection', () => {
const plugin = new ZodSmartCoercionPlugin()
const options = {} as any
plugin.init(options)

const coerce = (schema: any, input: unknown) => {
let coerced: unknown

options.clientInterceptors[0]({
procedure: {
'~orpc': {
inputSchema: schema,
},
},
input,
next: (options: any) => {
coerced = typeof options === 'object' ? options.input : input
},
})

return coerced
}

// `__proto__` must stay a normal own property instead of replacing the prototype
for (const schema of [z.object({ a: z.number() }), z.record(z.string())]) {
const coerced: any = coerce(schema, JSON.parse('{"a":"123","__proto__":{"polluted":"true"}}'))

expect(Object.hasOwn(coerced, '__proto__')).toBe(true)
expect(Object.getOwnPropertyDescriptor(coerced, '__proto__')!.value).toEqual({ polluted: 'true' })
expect(coerced.polluted).toBeUndefined()
expect(({} as any).polluted).toBeUndefined()
}

// `Object.prototype` members must not be used as sub-schemas
expect(coerce(z.object({ a: z.number() }), { a: '123', constructor: '456' })).toEqual({ a: 123, constructor: '456' })
expect(coerce(z.object({ a: z.number() }), { a: '123', toString: '456' })).toEqual({ a: 123, toString: '456' })
expect(coerce(z.object({ a: z.number() }).catchall(z.number()), { a: '123', constructor: '456' })).toEqual({ a: 123, constructor: 456 })
})
16 changes: 11 additions & 5 deletions packages/zod/src/coercer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
ZodTypeAny,
ZodUnion,
} from 'zod/v3'
import { guard, isObject } from '@orpc/shared'
import { guard, isObject, NullProtoObj } from '@orpc/shared'
import { ZodFirstPartyTypeKind } from 'zod/v3'
import { getCustomZodDef } from './schemas/base'

Expand Down Expand Up @@ -146,16 +146,22 @@ function zodCoerceInternal(
const schema_ = schema as ZodObject<{ [k: string]: ZodTypeAny }>

if (isObject(value)) {
const newObj: Record<string, unknown> = {}
/**
* Keys come from untrusted input, a null-prototype object keeps
* `newObj[key] = value` from reaching `Object.prototype` members like `__proto__`.
*/
const newObj: Record<string, unknown> = new NullProtoObj()

const shape = schema_.shape

const keys = new Set([
...Object.keys(value),
...Object.keys(schema_.shape),
...Object.keys(shape),
])

for (const k of keys) {
newObj[k] = zodCoerceInternal(
schema_.shape[k] ?? schema_._def.catchall,
(Object.hasOwn(shape, k) ? shape[k] : undefined) ?? schema_._def.catchall,
value[k],
)
}
Expand All @@ -170,7 +176,7 @@ function zodCoerceInternal(
const schema_ = schema as ZodRecord

if (isObject(value)) {
const newObj: any = {}
const newObj: any = new NullProtoObj()

for (const [k, v] of Object.entries(value)) {
const key = zodCoerceInternal(schema_._def.keyType, k)
Expand Down
39 changes: 39 additions & 0 deletions packages/zod/src/zod4/coercer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,42 @@ it('zodSmartCoercionPlugin ignore non-zod schemas', async () => {
const v = await import('valibot')
expect(coerce(v.object({}), val)).toBe(val)
})

it('zodSmartCoercionPlugin prevents prototype injection', () => {
const plugin = new ZodSmartCoercionPlugin()
const options = {} as any
plugin.init(options)

const coerce = (schema: any, input: unknown) => {
let coerced: unknown

options.clientInterceptors[0]({
procedure: {
'~orpc': {
inputSchema: schema,
},
},
input,
next: (options: any) => {
coerced = typeof options === 'object' ? options.input : input
},
})

return coerced
}

// `__proto__` must stay a normal own property instead of replacing the prototype
for (const schema of [z.object({ a: z.number() }), z.record(z.string(), z.string())]) {
const coerced: any = coerce(schema, JSON.parse('{"a":"123","__proto__":{"polluted":"true"}}'))

expect(Object.hasOwn(coerced, '__proto__')).toBe(true)
expect(Object.getOwnPropertyDescriptor(coerced, '__proto__')!.value).toEqual({ polluted: 'true' })
expect(coerced.polluted).toBeUndefined()
expect(({} as any).polluted).toBeUndefined()
}

// `Object.prototype` members must not be used as sub-schemas
expect(coerce(z.object({ a: z.number() }), { a: '123', constructor: '456' })).toEqual({ a: 123, constructor: '456' })
expect(coerce(z.object({ a: z.number() }), { a: '123', toString: '456' })).toEqual({ a: 123, toString: '456' })
expect(coerce(z.object({ a: z.number() }).catchall(z.number()), { a: '123', constructor: '456' })).toEqual({ a: 123, constructor: 456 })
})
16 changes: 11 additions & 5 deletions packages/zod/src/zod4/coercer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type {
$ZodType,
$ZodUnion,
} from 'zod/v4/core'
import { guard, isObject } from '@orpc/shared'
import { guard, isObject, NullProtoObj } from '@orpc/shared'

/**
* @deprecated Use [Smart Coercion Plugin](https://orpc.dev/docs/openapi/plugins/smart-coercion) instead.
Expand Down Expand Up @@ -164,15 +164,21 @@ export class experimental_ZodSmartCoercionPlugin<TContext extends Context> imple
}

if (isObject(value)) {
const newObj: Record<string, unknown> = {}
/**
* Keys come from untrusted input, a null-prototype object keeps
* `newObj[key] = value` from reaching `Object.prototype` members like `__proto__`.
*/
const newObj: Record<string, unknown> = new NullProtoObj()

const shape = object._zod.def.shape

const keys = new Set([
...Object.keys(value),
...Object.keys(object._zod.def.shape),
...Object.keys(shape),
])

for (const k of keys) {
const s = object._zod.def.shape[k] ?? object._zod.def.catchall
const s = (Object.hasOwn(shape, k) ? shape[k] : undefined) ?? object._zod.def.catchall
newObj[k] = s ? this.#coerce(s, value[k]) : value[k]
}

Expand All @@ -190,7 +196,7 @@ export class experimental_ZodSmartCoercionPlugin<TContext extends Context> imple
}

if (isObject(value)) {
const newObj: Record<string, unknown> = {}
const newObj: Record<string, unknown> = new NullProtoObj()

for (const [k, v] of Object.entries(value)) {
const key = this.#coerce(record._zod.def.keyType, k)
Expand Down
Loading