Skip to content

Commit 0b4ddd3

Browse files
authored
fix: stop error-code lookups resolving through Object.prototype (#1913)
Error-code lookups like `map[error.code]` resolved codes such as `toString` or `constructor` through `Object.prototype`. In `reconcileORPCError` the resolved prototype method has no `.data` schema, so a crafted wire error was stamped `defined`/`inferable` with its payload never validated. This adds a `getOwn` helper to `@orpc/shared` and uses it at every error-code lookup. ## Fixes - `reconcileORPCError` no longer promotes prototype-coded errors to defined; they downgrade like any unmapped code. - RPC and OpenAPI `encodeError` return the default error status for prototype-coded errors instead of an invalid `status: [Function]` response. - `catchORPCErrorCodes` (Effect) no longer invokes `Object.prototype.toString` as an error handler. - `SmartCoercionLinkPlugin` no longer probes prototype members when looking up error data schemas. - `createORPCErrorConstructorMap` constructors treat prototype-named codes as undefined config. ## Testing - One regression test per changed source file (fails without the guard), plus unit tests for `getOwn`. - `pnpm lint`, `pnpm type:check`, and affected package tests (182 files, 2070 tests) pass.
1 parent 3078b1c commit 0b4ddd3

13 files changed

Lines changed: 68 additions & 12 deletions

File tree

packages/contract/src/error-factory.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,12 @@ describe('createORPCErrorConstructorMap', () => {
225225
expect('BAD_GATEWAY' in constructors).toBe(true)
226226
expect('ANY_THING' in constructors).toBe(false)
227227
})
228+
229+
it('does not resolve error codes through Object.prototype', () => {
230+
const e = (constructors as any).toString()
231+
232+
expect(e.code).toEqual('toString')
233+
expect(e.defined).toEqual(false)
234+
expect(e.inferable).toEqual(false)
235+
})
228236
})

packages/contract/src/error-factory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ErrorMap, ErrorMapItem } from './error'
44
import type { AnySchema, InferSchemaInput, Schema } from './schema'
55

66
import { ORPCError } from '@orpc/client'
7-
import { resolveMaybeOptionalOptions } from '@orpc/shared'
7+
import { getOwn, resolveMaybeOptionalOptions } from '@orpc/shared'
88
import { ValidationError } from './error'
99
import { type } from './schema-utils'
1010

@@ -162,7 +162,7 @@ export function createORPCErrorConstructorMap<T extends ErrorMap>(errorMap: T):
162162

163163
const item: ORPCErrorConstructorMapItem<string, unknown> = (...rest) => {
164164
const options = resolveMaybeOptionalOptions(rest)
165-
const config = errorMap[code]
165+
const config = getOwn(errorMap as ErrorMap, code)
166166

167167
const error = new ORPCError(code, {
168168
message: options.message ?? config?.message,

packages/contract/src/error-utils.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,4 +280,11 @@ describe('reconcileORPCError', () => {
280280
expect(validated.inferable).toBe(true)
281281
})
282282
})
283+
284+
it('does not resolve error codes through Object.prototype', async () => {
285+
const error = new ORPCError('toString', { message: 'm', data: 'd' })
286+
;(error.defined as any) = true
287+
288+
expect((await reconcileORPCError({}, error)).defined).toBe(false)
289+
})
283290
})

packages/contract/src/error-utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AnyORPCError } from '@orpc/client'
22
import type { Writable } from '@orpc/shared'
33
import type { ErrorMap } from './error'
44
import { cloneORPCError } from '@orpc/client'
5+
import { getOwn } from '@orpc/shared'
56

67
export type MergedErrorMap<T1 extends ErrorMap, T2 extends ErrorMap>
78
= keyof T1 extends never | keyof T2
@@ -16,7 +17,7 @@ export async function reconcileORPCError(
1617
map: ErrorMap,
1718
error: AnyORPCError,
1819
): Promise<AnyORPCError> {
19-
const config = map[error.code]
20+
const config = getOwn(map, error.code)
2021

2122
if (!config) {
2223
// Do not check `error.inferable` here, because even when config is undefined,

packages/effect/src/error.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,13 @@ describe('catchORPCErrorCodes', () => {
206206

207207
expect(exit).toEqual(Exit.fail(error))
208208
})
209+
210+
it('does not resolve error codes through Object.prototype', async () => {
211+
const error = new ORPCError('toString')
212+
const exit = await Effect.runPromiseExit(
213+
catchORPCErrorCodes(Effect.fail(error), {} as any) as Effect.Effect<unknown, unknown>,
214+
)
215+
216+
expect(exit).toEqual(Exit.fail(error))
217+
})
209218
})

packages/effect/src/error.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AnyORPCError, ORPCErrorCode } from '@orpc/server'
22
import { ORPCError } from '@orpc/server'
3+
import { getOwn } from '@orpc/shared'
34
import { Effect, Function } from 'effect'
45

56
/**
@@ -144,8 +145,8 @@ export const catchORPCErrorCodes: {
144145
cases: Record<string, ((error: AnyORPCError) => Effect.Effect<any, any, any>) | undefined>,
145146
) => self.pipe(
146147
Effect.catchIf(
147-
(error): error is AnyORPCError => error instanceof ORPCError && typeof cases[error.code] === 'function',
148-
error => cases[error.code]!(error),
148+
(error): error is AnyORPCError => error instanceof ORPCError && typeof getOwn(cases, error.code) === 'function',
149+
error => getOwn(cases, error.code)!(error),
149150
),
150151
),
151152
)

packages/json-schema/src/smart-coercion-link-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { JsonSchemaConverter } from './convert'
55
import type { JsonSchema } from './types'
66
import { cloneORPCError, ORPCError } from '@orpc/client'
77
import { getProcedureContractOrThrow } from '@orpc/contract'
8-
import { toArray } from '@orpc/shared'
8+
import { getOwn, toArray } from '@orpc/shared'
99
import { JsonSchemaCoercer } from './coercer'
1010
import { DelegatingJsonSchemaConverter } from './convert'
1111
import { StandardJsonSchemaConverter } from './standard-json-schema-converter'
@@ -68,7 +68,7 @@ export class SmartCoercionLinkPlugin<T extends ClientContext> implements Standar
6868
}
6969

7070
const errorMap: ErrorMap = procedure['~orpc'].errorMap
71-
const dataSchema = errorMap[error.code]?.data
71+
const dataSchema = getOwn(errorMap, error.code)?.data
7272

7373
if (!dataSchema) {
7474
throw error

packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,13 @@ describe('openAPIHandlerCodec', () => {
858858
expect(serializer.serialize).toHaveBeenNthCalledWith(2, secondError.toJSON())
859859
})
860860

861+
it('does not resolve error codes through Object.prototype', async () => {
862+
const serializer = { serialize: vi.fn(), deserialize: vi.fn() } as any
863+
const codec = new OpenAPIHandlerCodec({ procedure: os.handler(vi.fn()) }, { serializer })
864+
865+
expect((await codec.encodeError(new ORPCError('toString' as any))).status).toEqual(DEFAULT_ERROR_STATUS)
866+
})
867+
861868
it('can custom error status via errorStatuses option', () => {
862869
const serializer = {
863870
serialize: vi.fn().mockReturnValueOnce('__serialized_override__'),

packages/openapi/src/adapters/standard/openapi-handler-codec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { OpenAPIMeta } from '../../meta'
77
import type { OpenAPIMatcherOptions } from './openapi-matcher'
88
import { COMMON_ERROR_STATUS_MAP } from '@orpc/client'
99
import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS } from '@orpc/server'
10-
import { isPlainObject, isTypescriptObject, NullProtoObj, parseEmptyableJSON, stringifyJSON } from '@orpc/shared'
10+
import { getOwn, isPlainObject, isTypescriptObject, NullProtoObj, parseEmptyableJSON, stringifyJSON } from '@orpc/shared'
1111
import { parseStandardUrl } from '@standardserver/core'
1212
import {
1313
DEFAULT_OPENAPI_INPUT_STRUCTURE,
@@ -137,7 +137,7 @@ export class OpenAPIHandlerCodecCore<T extends Context> {
137137
}
138138

139139
encodeError(error: AnyORPCError): Promisable<StandardResponse> {
140-
const status = this.errorStatusMap[error.code] ?? DEFAULT_ERROR_STATUS
140+
const status = getOwn(this.errorStatusMap, error.code) ?? DEFAULT_ERROR_STATUS
141141

142142
return {
143143
status,

packages/server/src/adapters/standard/rpc-handler-codec.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,14 @@ describe('rpcHandlerCodec', () => {
225225
expect(serializer.serialize).toHaveBeenCalledWith(error.toJSON())
226226
})
227227

228+
it('does not resolve error codes through Object.prototype', async () => {
229+
const serializer = { serialize: vi.fn(), deserialize: vi.fn() } as any
230+
const codec = new RPCHandlerCodec(router, { serializer })
231+
const error = new ORPCError('toString' as any)
232+
233+
expect((await codec.encodeError(error, procedure as any, ['ping'], options as any)).status).toEqual(DEFAULT_ERROR_STATUS)
234+
})
235+
228236
it('custom status with errorStatusCodes option', () => {
229237
const serializer = {
230238
serialize: vi.fn()

0 commit comments

Comments
 (0)