Skip to content

Commit 99d5d75

Browse files
jameskranzclaude
andauthored
feat(server,contract): guard against primitive values in router tree traversal (#1522)
## Summary - Router utility functions that use `for (const key in router)` loops without validating that values are objects crash with `RangeError: Maximum call stack size exceeded` when a router module exports primitives (e.g., `export const FOO = "bar"`). - **Server package**: Added type guards to `enhanceRouter`, `traverseContractProcedures`, and `unlazyRouter` in `packages/server/src/router-utils.ts`. - **Contract package**: Applied the same fix to `enhanceContractRouter`, `minifyContractRouter`, and `populateContractRouterPaths` in `packages/contract/src/router-utils.ts`. - Added tests covering strings, single-character strings, numbers, booleans, null, and undefined for all affected functions in both packages. ## Test plan - [x] All new tests pass (server + contract packages) - [x] All existing tests continue to pass - [x] Verified tests fail without the fix (infinite recursion crashes the test worker) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Router and contract utilities now safely handle primitive and non-object inputs, preventing erroneous traversal, stack/recursion issues, and preserving primitive exports (strings/numbers/booleans/null/undefined). * **Tests** * Added tests covering mixed exports and primitive-only cases (including single-character string edge cases) to verify procedures are preserved and primitives remain unchanged. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6a3f818 commit 99d5d75

File tree

4 files changed

+235
-1
lines changed

4 files changed

+235
-1
lines changed

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

Lines changed: 108 additions & 0 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'
@@ -74,6 +75,113 @@ it('minifyContractRouter', () => {
7475
expect((minified as any).nested.pong).toEqual(minifiedPong)
7576
})
7677

78+
describe('contract modules that export primitives alongside procedures', () => {
79+
// Simulates: import * as userContract from './contracts/user'
80+
// where the module exports contract procedures AND constants like:
81+
// export const getUser = oc.input(userSchema)
82+
// export const listUsers = oc.input(listSchema)
83+
// export const API_VERSION = 'v2'
84+
// export const MAX_PAGE_SIZE = 100
85+
// export const ENABLE_CACHE = true
86+
87+
const moduleWithPrimitives = {
88+
getUser: ping,
89+
listUsers: pong,
90+
API_VERSION: 'v2',
91+
MAX_PAGE_SIZE: 100,
92+
ENABLE_CACHE: true,
93+
DEPRECATED: null,
94+
OPTIONAL_FEATURE: undefined,
95+
} as any
96+
97+
describe('enhanceContractRouter', () => {
98+
const options = { errorMap: {}, prefix: '/api', tags: ['api'] } as const
99+
100+
it('enhances procedures and passes through primitive exports', () => {
101+
const enhanced = enhanceContractRouter(moduleWithPrimitives, options) as unknown as {
102+
getUser: AnyContractProcedure
103+
listUsers: AnyContractProcedure
104+
API_VERSION: string
105+
MAX_PAGE_SIZE: number
106+
ENABLE_CACHE: boolean
107+
}
108+
expect(isContractProcedure(enhanced.getUser)).toBe(true)
109+
expect(isContractProcedure(enhanced.listUsers)).toBe(true)
110+
expect(enhanced.API_VERSION).toBe('v2')
111+
expect(enhanced.MAX_PAGE_SIZE).toBe(100)
112+
expect(enhanced.ENABLE_CACHE).toBe(true)
113+
})
114+
115+
it('handles single-character string exports without stack overflow', () => {
116+
// Single-char strings are the worst case: for...in on 'v' yields key '0',
117+
// and 'v'[0] === 'v' creates an infinite loop
118+
const moduleWithFlag = { getUser: ping, v: 'v' } as any
119+
expect(() => enhanceContractRouter(moduleWithFlag, options)).not.toThrow()
120+
})
121+
})
122+
123+
describe('minifyContractRouter', () => {
124+
it('minifies procedures and passes through primitive exports', () => {
125+
const minified = minifyContractRouter(moduleWithPrimitives)
126+
expect(isContractProcedure((minified as any).getUser)).toBe(true)
127+
expect(isContractProcedure((minified as any).listUsers)).toBe(true)
128+
expect((minified as any).API_VERSION).toBe('v2')
129+
expect((minified as any).MAX_PAGE_SIZE).toBe(100)
130+
})
131+
132+
it('handles single-character string exports without stack overflow', () => {
133+
const moduleWithFlag = { getUser: ping, v: 'v' } as any
134+
expect(() => minifyContractRouter(moduleWithFlag)).not.toThrow()
135+
})
136+
})
137+
138+
describe('populateContractRouterPaths', () => {
139+
it('populates procedure paths and passes through primitive exports', () => {
140+
const moduleForPaths = {
141+
getUser: oc.input(inputSchema),
142+
listUsers: oc.output(outputSchema),
143+
API_VERSION: 'v2',
144+
MAX_PAGE_SIZE: 100,
145+
ENABLE_CACHE: true,
146+
} as any
147+
const populated = populateContractRouterPaths(moduleForPaths) as unknown as {
148+
getUser: AnyContractProcedure
149+
listUsers: AnyContractProcedure
150+
API_VERSION: string
151+
MAX_PAGE_SIZE: number
152+
ENABLE_CACHE: boolean
153+
}
154+
expect(isContractProcedure(populated.getUser)).toBe(true)
155+
expect(populated.getUser['~orpc'].route.path).toBe('/getUser')
156+
expect(isContractProcedure(populated.listUsers)).toBe(true)
157+
expect(populated.listUsers['~orpc'].route.path).toBe('/listUsers')
158+
expect(populated.API_VERSION).toBe('v2')
159+
expect(populated.MAX_PAGE_SIZE).toBe(100)
160+
})
161+
162+
it('handles single-character string exports without stack overflow', () => {
163+
const moduleWithFlag = { getUser: oc.input(inputSchema), v: 'v' } as any
164+
expect(() => populateContractRouterPaths(moduleWithFlag)).not.toThrow()
165+
})
166+
})
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+
})
183+
})
184+
77185
it('populateContractRouterPaths', () => {
78186
const contract = {
79187
ping: oc.input(inputSchema),

packages/contract/src/router-utils.ts

Lines changed: 16 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

@@ -53,6 +57,10 @@ export function enhanceContractRouter<T extends AnyContractRouter, TErrorMap ext
5357
return enhanced as any
5458
}
5559

60+
if (typeof router !== 'object' || router === null) {
61+
return router as any
62+
}
63+
5664
const enhanced: Record<string, any> = {}
5765

5866
for (const key in router) {
@@ -83,6 +91,10 @@ export function minifyContractRouter(router: AnyContractRouter): AnyContractRout
8391
return procedure
8492
}
8593

94+
if (typeof router !== 'object' || router === null) {
95+
return router as any
96+
}
97+
8698
const json: Record<string, AnyContractRouter> = {}
8799

88100
for (const key in router) {
@@ -128,6 +140,10 @@ export function populateContractRouterPaths<T extends AnyContractRouter>(router:
128140
return router as any
129141
}
130142

143+
if (typeof router !== 'object' || router === null) {
144+
return router as any
145+
}
146+
131147
const populated: Record<string, any> = {}
132148

133149
for (const key in router) {

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

Lines changed: 91 additions & 0 deletions
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'
@@ -220,3 +221,93 @@ it('unlazyRouter', async () => {
220221
},
221222
})
222223
})
224+
225+
describe('router modules that export primitives alongside procedures', () => {
226+
// Simulates: import * as userRouter from './routes/user'
227+
// where the module exports procedures AND constants like:
228+
// export const getUser = os.handler(...)
229+
// export const listUsers = os.handler(...)
230+
// export const API_VERSION = 'v2'
231+
// export const MAX_PAGE_SIZE = 100
232+
// export const ENABLE_CACHE = true
233+
234+
const moduleWithPrimitives = {
235+
getUser: pong,
236+
listUsers: pong,
237+
API_VERSION: 'v2',
238+
MAX_PAGE_SIZE: 100,
239+
ENABLE_CACHE: true,
240+
DEPRECATED: null,
241+
OPTIONAL_FEATURE: undefined,
242+
} as any
243+
244+
const defaultOptions = {
245+
errorMap: {},
246+
middlewares: [],
247+
prefix: undefined,
248+
tags: [],
249+
dedupeLeadingMiddlewares: false,
250+
} as const
251+
252+
describe('enhanceRouter', () => {
253+
it('enhances procedures and passes through primitive exports', () => {
254+
const enhanced = enhanceRouter(moduleWithPrimitives, defaultOptions) as unknown as {
255+
getUser: AnyProcedure
256+
listUsers: AnyProcedure
257+
API_VERSION: string
258+
MAX_PAGE_SIZE: number
259+
ENABLE_CACHE: boolean
260+
}
261+
expect(enhanced.getUser['~orpc']).toBeDefined()
262+
expect(enhanced.listUsers['~orpc']).toBeDefined()
263+
expect(enhanced.API_VERSION).toBe('v2')
264+
expect(enhanced.MAX_PAGE_SIZE).toBe(100)
265+
expect(enhanced.ENABLE_CACHE).toBe(true)
266+
})
267+
268+
it('handles single-character string exports without stack overflow', () => {
269+
// Single-char strings are the worst case: for...in on 'v' yields key '0',
270+
// and 'v'[0] === 'v' creates an infinite loop
271+
const moduleWithFlag = { getUser: pong, v: 'v' } as any
272+
expect(() => enhanceRouter(moduleWithFlag, defaultOptions)).not.toThrow()
273+
})
274+
})
275+
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+
292+
describe('traverseContractProcedures', () => {
293+
it('traverses procedures and skips primitive, null, and undefined exports', () => {
294+
const callback = vi.fn()
295+
expect(() =>
296+
traverseContractProcedures({ router: moduleWithPrimitives, path: [] }, callback),
297+
).not.toThrow()
298+
expect(callback).toHaveBeenCalledTimes(2)
299+
expect(callback).toHaveBeenCalledWith({ contract: pong, path: ['getUser'] })
300+
expect(callback).toHaveBeenCalledWith({ contract: pong, path: ['listUsers'] })
301+
})
302+
})
303+
304+
describe('unlazyRouter', () => {
305+
it('resolves procedures and preserves primitive exports', async () => {
306+
const result = await unlazyRouter(moduleWithPrimitives)
307+
expect(result.getUser).toEqual(pong)
308+
expect(result.listUsers).toEqual(pong)
309+
expect(result.API_VERSION).toBe('v2')
310+
expect(result.MAX_PAGE_SIZE).toBe(100)
311+
})
312+
})
313+
})

packages/server/src/router-utils.ts

Lines changed: 20 additions & 1 deletion
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

@@ -150,6 +154,10 @@ export function enhanceRouter<
150154
return enhanced as any
151155
}
152156

157+
if (typeof router !== 'object' || router === null) {
158+
return router as any
159+
}
160+
153161
const enhanced = {} as Record<string, any>
154162

155163
for (const key in router) {
@@ -184,6 +192,13 @@ export function traverseContractProcedures(
184192
callback: (options: TraverseContractProcedureCallbackOptions) => void,
185193
lazyOptions: LazyTraverseContractProceduresOptions[] = [],
186194
): LazyTraverseContractProceduresOptions[] {
195+
// Guard before reading the hidden-contract symbol so that null/undefined
196+
// child exports don't crash in `getHiddenRouterContract`. Primitives like
197+
// strings autobox safely; only null/undefined throw on symbol access.
198+
if (typeof options.router !== 'object' || options.router === null) {
199+
return lazyOptions
200+
}
201+
187202
let currentRouter: AnyContractRouter | Lazyable<AnyRouter> = options.router
188203

189204
const hiddenContract = getHiddenRouterContract(options.router)
@@ -206,7 +221,7 @@ export function traverseContractProcedures(
206221
})
207222
}
208223

209-
else {
224+
else if (typeof currentRouter === 'object' && currentRouter !== null) {
210225
for (const key in currentRouter) {
211226
traverseContractProcedures(
212227
{
@@ -254,6 +269,10 @@ export async function unlazyRouter<T extends AnyRouter>(router: T): Promise<Unla
254269
return router as any
255270
}
256271

272+
if (typeof router !== 'object' || router === null) {
273+
return router as any
274+
}
275+
257276
const unlazied = {} as Record<string, any>
258277

259278
for (const key in router) {

0 commit comments

Comments
 (0)