Skip to content

Commit 99ce3de

Browse files
authored
fix(nest): keep non-contract params and harden path param handling (#1818)
The NestJS adapter previously rebuilt request params from the contract's dynamic path params only, so anything else NestJS matched was dropped, and param names were read and written through the prototype chain. Params matched outside the contract path now reach the procedure input, and path params can no longer touch the prototype. ## Fixes - Params from dynamic controller or global prefixes (e.g. `@Controller(':tenant')`) are no longer dropped, including when the contract path has no dynamic params at all. - A non-rest param literally named `path` no longer collides with Express's wildcard key and disappears. - Params named like `__proto__` become own properties on a null-prototype object instead of being silently lost or mutating the prototype. - Rest params are resolved from the adapter's own wildcard key (`path` on Express, `*` on Fastify) and the raw wildcard key no longer leaks into input. ## Testing - New cases run on both Express and Fastify adapters: dynamic controller prefixes with static/dynamic/rest contract paths, a `/files/{path}` route, `__proto__` injection, and a custom request parser without a wildcard param. - 91 tests pass; `implement.ts` sits at 100% statement and branch coverage. ## Chore - `worker-configuration.d.ts` files regenerated by wrangler (workerd version bump).
1 parent e584b49 commit 99ce3de

4 files changed

Lines changed: 183 additions & 22 deletions

File tree

packages/cloudflare/worker-configuration.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable */
22
// Generated by Wrangler by running `wrangler types` (hash: e624d76b8500cfee2091bb6c84ee404f)
3-
// Runtime types generated with workerd@1.20260722.1 2026-07-01
3+
// Runtime types generated with workerd@1.20260730.1 2026-07-01
44
interface __BaseEnv_Env {
55
RATELIMIT_3_10S: RateLimit;
66
PUBLISHER_DON: DurableObjectNamespace /* PublisherDO */;

packages/nest/src/implement.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,127 @@ describe('routing', () => {
278278
})
279279
})
280280
})
281+
282+
describe.each([
283+
['express adapter', undefined],
284+
['fastify adapter', new FastifyAdapter()],
285+
] as const)('params edge cases with %s', async (_, adapter) => {
286+
const contract = {
287+
staticPath: oc.meta(openapi({
288+
path: '/static',
289+
method: 'GET',
290+
})).input(z.object({ tenant: z.string() })),
291+
292+
dynamicPath: oc.meta(openapi({
293+
path: '/dynamic/{id}',
294+
method: 'GET',
295+
})).input(z.object({ tenant: z.string(), id: z.string() })),
296+
297+
restPath: oc.meta(openapi({
298+
path: '/rest/{+rest}',
299+
method: 'GET',
300+
})).input(z.object({ tenant: z.string(), rest: z.string() })),
301+
302+
pathNamedParam: oc.meta(openapi({
303+
path: '/files/{path}',
304+
method: 'GET',
305+
})).input(z.object({ tenant: z.string(), path: z.string() })),
306+
}
307+
308+
@Controller('/:tenant')
309+
class TenantController {
310+
@Implement(contract.staticPath)
311+
staticPath() {
312+
return implement(contract.staticPath).handler(({ input }) => input)
313+
}
314+
315+
@Implement(contract.dynamicPath)
316+
dynamicPath() {
317+
return implement(contract.dynamicPath).handler(({ input }) => input)
318+
}
319+
320+
@Implement(contract.restPath)
321+
restPath() {
322+
return implement(contract.restPath).handler(({ input }) => input)
323+
}
324+
325+
@Implement(contract.pathNamedParam)
326+
pathNamedParam() {
327+
return implement(contract.pathNamedParam).handler(({ input }) => input)
328+
}
329+
}
330+
331+
const protoContract = oc.meta(openapi({
332+
path: '/proto/{+__proto__}',
333+
inputStructure: 'detailed',
334+
}))
335+
336+
@Controller()
337+
class ProtoController {
338+
@Implement(protoContract)
339+
proto() {
340+
return implement(protoContract).handler(({ input }) => {
341+
const params = (input as any).params
342+
343+
return {
344+
entries: Object.entries(params),
345+
constructor: typeof params.constructor,
346+
}
347+
})
348+
}
349+
}
350+
351+
const moduleRef = await Test.createTestingModule({
352+
controllers: [TenantController, ProtoController],
353+
}).compile()
354+
355+
const app = moduleRef.createNestApplication(adapter as any)
356+
await app.init()
357+
358+
if (adapter) {
359+
await app.getHttpAdapter().getInstance().ready()
360+
}
361+
362+
const httpServer = app.getHttpServer()
363+
364+
it('should keep dynamic controller prefix params when the contract path has none', async () => {
365+
const res = await supertest(httpServer).get('/acme/static')
366+
367+
expect(res.statusCode).toEqual(200)
368+
expect(res.body).toEqual({ tenant: 'acme' })
369+
})
370+
371+
it('should keep dynamic controller prefix params alongside contract params', async () => {
372+
const res = await supertest(httpServer).get('/acme/dynamic/123')
373+
374+
expect(res.statusCode).toEqual(200)
375+
expect(res.body).toEqual({ tenant: 'acme', id: '123' })
376+
})
377+
378+
it('should keep dynamic controller prefix params alongside contract rest params', async () => {
379+
const res = await supertest(httpServer).get('/acme/rest/some/long/path')
380+
381+
expect(res.statusCode).toEqual(200)
382+
expect(res.body).toEqual({ tenant: 'acme', rest: 'some/long/path' })
383+
})
384+
385+
it('should keep a non-rest param literally named `path`', async () => {
386+
const res = await supertest(httpServer).get('/acme/files/xxx')
387+
388+
expect(res.statusCode).toEqual(200)
389+
expect(res.body).toEqual({ tenant: 'acme', path: 'xxx' })
390+
})
391+
392+
it('should treat params named like `__proto__` as own properties without polluting the prototype', async () => {
393+
const res = await supertest(httpServer).post('/proto/some/value')
394+
395+
expect(res.statusCode).toEqual(200)
396+
expect(res.body).toEqual({
397+
entries: [['__proto__', 'some/value']],
398+
constructor: 'undefined',
399+
})
400+
})
401+
})
281402
})
282403

283404
describe('response status, headers and body should follow standardserver', () => {
@@ -1209,6 +1330,44 @@ describe('compatibility', () => {
12091330
)
12101331
})
12111332

1333+
it('ignores contract rest params when custom request parser provides no wildcard param', async () => {
1334+
const contract = oc.meta(openapi({
1335+
path: '/parser-rest/{+rest}',
1336+
inputStructure: 'detailed',
1337+
}))
1338+
1339+
@Controller()
1340+
class ImplController {
1341+
@Implement(contract)
1342+
parserRest() {
1343+
return implement(contract).handler(({ input }) => (input as any).params)
1344+
}
1345+
}
1346+
1347+
const moduleRef = await Test.createTestingModule({
1348+
controllers: [ImplController],
1349+
imports: [
1350+
ORPCModule.forRoot({
1351+
toNestStandardLazyRequest: () => ({
1352+
url: '/parser-rest/value',
1353+
method: 'POST',
1354+
headers: {},
1355+
resolveBody: async () => undefined,
1356+
params: { other: '__OTHER__' },
1357+
} satisfies NestStandardLazyRequest),
1358+
}),
1359+
],
1360+
}).compile()
1361+
1362+
const app = moduleRef.createNestApplication()
1363+
await app.init()
1364+
1365+
const res = await supertest(app.getHttpServer()).post('/parser-rest/some/value')
1366+
1367+
expect(res.statusCode).toEqual(200)
1368+
expect(res.body).toEqual({ other: '__OTHER__' })
1369+
})
1370+
12121371
it('procedure path[] should use meta.path or fall back to empty', async () => {
12131372
const contract = {
12141373
without: oc.meta(openapi({

packages/nest/src/implement.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { DEFAULT_OPENAPI_METHOD, getDynamicPathParams, getOpenAPIMeta } from '@o
1515
import { OpenAPIHandlerCodecCore } from '@orpc/openapi/standard'
1616
import { DEFAULT_SUCCESS_STATUS, getRouter, Procedure, unlazy } from '@orpc/server'
1717
import { StandardHandler } from '@orpc/server/standard'
18-
import { isAsyncIteratorObject, mergeHttpPath, stringifyJSON, value } from '@orpc/shared'
18+
import { isAsyncIteratorObject, mergeHttpPath, NullProtoObj, stringifyJSON, value } from '@orpc/shared'
1919
import { flattenStandardHeader, generateContentDisposition } from '@standardserver/core'
2020
import { toEventStream, toStandardLazyRequest } from '@standardserver/node'
2121
import { mergeMap } from 'rxjs'
@@ -293,39 +293,41 @@ export class ImplementInterceptor implements NestInterceptor {
293293
}
294294
}
295295

296-
function flattenParamValue(value: undefined | string | string[]): undefined | string {
296+
function flattenParamValue(value: string | string[]): string {
297297
return Array.isArray(value) ? value.join('/') : value
298298
}
299299

300300
function toORPCOpenAPIParams(contract: AnyProcedureContract, params: NestStandardLazyRequest['params']): undefined | Record<string, string> {
301301
const meta = getOpenAPIMeta(contract)
302302

303-
/* c8 ignore start - there cases almost never happen only for type guard purpose */
304-
if (!params || meta?.path === undefined) {
303+
if (!params || meta?.path === undefined || Object.keys(params).length === 0) {
305304
return undefined
306305
}
307-
/* c8 ignore stop */
308306

309-
const dynamicParams = getDynamicPathParams(meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path)
310-
if (!dynamicParams) {
311-
return undefined
312-
}
307+
// NullProtoObj prevents prototype injection when a param is named like `__proto__`
308+
const orpcParams: Record<string, string> = new NullProtoObj()
309+
// express use `path` while fastify use `*` for rest matching
310+
const restKey = Object.hasOwn(params, '*') ? '*' : 'path'
311+
312+
for (const [key, value] of Object.entries(params)) {
313+
if (key === restKey) {
314+
const restParams = getDynamicPathParams(
315+
meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path,
316+
)?.filter(c => c.allowsSlash)
313317

314-
return dynamicParams.reduce((acc: Record<string, string>, config) => {
315-
const value = config.allowsSlash
316-
? flattenParamValue(params?.['*'] ?? params?.path) // express use `path` while fastify use `*` for rest matching
317-
: flattenParamValue(params?.[config.parameterName])
318+
if (restParams?.length) {
319+
for (const c of restParams) {
320+
orpcParams[c.parameterName] = flattenParamValue(value)
321+
}
318322

319-
/* c8 ignore start - this case almost never happen only for type guard purpose */
320-
if (value === undefined) {
321-
return acc
323+
continue
324+
}
322325
}
323-
/* c8 ignore stop */
324326

325-
acc[config.parameterName] = value
327+
orpcParams[key] = flattenParamValue(value)
328+
}
326329

327-
return acc
328-
}, {})
330+
return orpcParams
329331
}
330332

331333
function toNestPattern(path: `/${string}`): `/${string}` {

playgrounds/cloudflare/worker-configuration.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable */
22
// Generated by Wrangler by running `wrangler types` (hash: ddedf26e1bdb86a42eff01a24b02f0d4)
3-
// Runtime types generated with workerd@1.20260722.1 2026-07-01 nodejs_compat
3+
// Runtime types generated with workerd@1.20260730.1 2026-07-01 nodejs_compat
44
interface __BaseEnv_Env {
55
PUBLISHER_DON: DurableObjectNamespace<import("./worker/index").PublisherDO>;
66
CHAT_ROOM_DON: DurableObjectNamespace<import("./worker/index").ChatRoomDO>;

0 commit comments

Comments
 (0)