Skip to content

Commit 92b7eab

Browse files
jameskranzclaude
andcommitted
fix(server,contract): harden getRouter against primitive traversal
- Add typeof guard to getRouter and getContractRouter, mirroring the pattern used by enhance/minify/populate/unlazy in this PR. Without it, traversal walks character indices ('v'[0] === 'v') and returns garbage instead of undefined. - Replace inferred-union test bindings with precise type casts so property assertions are statically checked instead of any-cast. - Add primitive-traversal coverage for getRouter / getContractRouter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f2fc836 commit 92b7eab

File tree

4 files changed

+63
-3
lines changed

4 files changed

+63
-3
lines changed

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AnyContractProcedure } from './procedure'
12
import { inputSchema, outputSchema, ping, pong, router } from '../tests/shared'
23
import { oc } from './builder'
34
import { isContractProcedure } from './procedure'
@@ -97,7 +98,13 @@ describe('contract modules that export primitives alongside procedures', () => {
9798
const options = { errorMap: {}, prefix: '/api', tags: ['api'] } as const
9899

99100
it('enhances procedures and passes through primitive exports', () => {
100-
const enhanced = enhanceContractRouter(moduleWithPrimitives, options)
101+
const enhanced = enhanceContractRouter(moduleWithPrimitives, options) as {
102+
getUser: AnyContractProcedure
103+
listUsers: AnyContractProcedure
104+
API_VERSION: string
105+
MAX_PAGE_SIZE: number
106+
ENABLE_CACHE: boolean
107+
}
101108
expect(isContractProcedure(enhanced.getUser)).toBe(true)
102109
expect(isContractProcedure(enhanced.listUsers)).toBe(true)
103110
expect(enhanced.API_VERSION).toBe('v2')
@@ -137,7 +144,13 @@ describe('contract modules that export primitives alongside procedures', () => {
137144
MAX_PAGE_SIZE: 100,
138145
ENABLE_CACHE: true,
139146
} as any
140-
const populated = populateContractRouterPaths(moduleForPaths)
147+
const populated = populateContractRouterPaths(moduleForPaths) as {
148+
getUser: AnyContractProcedure
149+
listUsers: AnyContractProcedure
150+
API_VERSION: string
151+
MAX_PAGE_SIZE: number
152+
ENABLE_CACHE: boolean
153+
}
141154
expect(isContractProcedure(populated.getUser)).toBe(true)
142155
expect(populated.getUser['~orpc'].route.path).toBe('/getUser')
143156
expect(isContractProcedure(populated.listUsers)).toBe(true)
@@ -151,6 +164,22 @@ describe('contract modules that export primitives alongside procedures', () => {
151164
expect(() => populateContractRouterPaths(moduleWithFlag)).not.toThrow()
152165
})
153166
})
167+
168+
describe('getContractRouter', () => {
169+
it('returns undefined when path traverses past a primitive export', () => {
170+
expect(getContractRouter(moduleWithPrimitives, ['API_VERSION', 'length'])).toBeUndefined()
171+
expect(getContractRouter(moduleWithPrimitives, ['MAX_PAGE_SIZE', 'toFixed'])).toBeUndefined()
172+
expect(getContractRouter(moduleWithPrimitives, ['ENABLE_CACHE', 'valueOf'])).toBeUndefined()
173+
})
174+
175+
it('returns undefined for single-character string exports instead of indexed characters', () => {
176+
// Without the typeof guard, getContractRouter(['v', '0']) returns 'v'
177+
// because 'v'[0] === 'v', walking character indices instead of bailing out.
178+
const moduleWithFlag = { getUser: ping, v: 'v' } as any
179+
expect(getContractRouter(moduleWithFlag, ['v', '0'])).toBeUndefined()
180+
expect(getContractRouter(moduleWithFlag, ['v', '0', '0', '0'])).toBeUndefined()
181+
})
182+
})
154183
})
155184

156185
it('populateContractRouterPaths', () => {

packages/contract/src/router-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export function getContractRouter(router: AnyContractRouter, path: readonly stri
2222
return undefined
2323
}
2424

25+
if (typeof current !== 'object') {
26+
return undefined
27+
}
28+
2529
current = current[segment]
2630
}
2731

packages/server/src/router-utils.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AnyProcedure } from './procedure'
12
import { enhanceRoute } from '@orpc/contract'
23
import { contract, ping, pingMiddleware, pong, router } from '../tests/shared'
34
import { getLazyMeta, isLazy, unlazy } from './lazy'
@@ -250,7 +251,13 @@ describe('router modules that export primitives alongside procedures', () => {
250251

251252
describe('enhanceRouter', () => {
252253
it('enhances procedures and passes through primitive exports', () => {
253-
const enhanced = enhanceRouter(moduleWithPrimitives, defaultOptions)
254+
const enhanced = enhanceRouter(moduleWithPrimitives, defaultOptions) as {
255+
getUser: AnyProcedure
256+
listUsers: AnyProcedure
257+
API_VERSION: string
258+
MAX_PAGE_SIZE: number
259+
ENABLE_CACHE: boolean
260+
}
254261
expect(enhanced.getUser['~orpc']).toBeDefined()
255262
expect(enhanced.listUsers['~orpc']).toBeDefined()
256263
expect(enhanced.API_VERSION).toBe('v2')
@@ -266,6 +273,22 @@ describe('router modules that export primitives alongside procedures', () => {
266273
})
267274
})
268275

276+
describe('getRouter', () => {
277+
it('returns undefined when path traverses past a primitive export', () => {
278+
expect(getRouter(moduleWithPrimitives, ['API_VERSION', 'length'])).toBeUndefined()
279+
expect(getRouter(moduleWithPrimitives, ['MAX_PAGE_SIZE', 'toFixed'])).toBeUndefined()
280+
expect(getRouter(moduleWithPrimitives, ['ENABLE_CACHE', 'valueOf'])).toBeUndefined()
281+
})
282+
283+
it('returns undefined for single-character string exports instead of indexed characters', () => {
284+
// Without the typeof guard, getRouter(['v', '0']) returns 'v' because
285+
// 'v'[0] === 'v', walking character indices instead of bailing out.
286+
const moduleWithFlag = { getUser: pong, v: 'v' } as any
287+
expect(getRouter(moduleWithFlag, ['v', '0'])).toBeUndefined()
288+
expect(getRouter(moduleWithFlag, ['v', '0', '0', '0'])).toBeUndefined()
289+
})
290+
})
291+
269292
describe('traverseContractProcedures', () => {
270293
it('traverses procedures and skips primitive exports', () => {
271294
// null/undefined are excluded here because getHiddenRouterContract

packages/server/src/router-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export function getRouter<T extends Lazyable<AnyRouter | undefined>>(
2727
return undefined as any
2828
}
2929

30+
if (typeof current !== 'object') {
31+
return undefined as any
32+
}
33+
3034
if (!isLazy(current)) {
3135
current = current[segment]
3236

0 commit comments

Comments
 (0)