Repository navigation
fix(rpc): reject batch messages with non-string ids - #2304
Conversation
BatchHandlerPlugin validated untrusted batch JSON with isClientPeerSendMessage
alone, which assumes isPeerMessage already ran and never checks id or binary.
Ids like true, {}, [], null, numbers or a missing id were accepted, the
procedure ran and the id was echoed back; a deeply nested array id made the
adapter's JSON.stringify throw a RangeError. Require isPeerMessage as well,
like the message-port handler does, so such batches get a 400.
Similar to the tRPC fix in trpc/trpc#7477.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CM5zP1QBSVRmibkRqzK3
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This localized fix tightens batch request and response validation for malformed peer messages, while preserving valid string-ID traffic. It includes focused regression coverage and does not alter deployment, authentication, billing, or unrelated runtime paths. You can add or adjust custom eligibility rules. Learn more. |
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/mcp
@orpc/msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
batch.tsvalidation — both the GET (?data=) and POST body paths now reject!isPeerMessage(m) || !isClientPeerSendMessage(m).isPeerMessageis shallow (typeof id === 'string',typeof kind === 'string',binaryundefined/Uint8Array/Blob), so a nested-array id fails fast instead of overflowingJSON.stringifyduring response serialization.- Regression tests —
batch.test.tsuses string ids throughout and adds POST + GET cases fortrue/{}/[]/null/0/missing ids and a 10k-deep nested array id, each asserting400and zero handler calls. - Fixture updates —
get-method-csrf-protection.test.tsandnode/batch-response-compression-handler-plugin.test.tsswitch hand-built batches to string ids.
I traced the other peer entry points: message-port, crossws and websocket all gate on decodePeerMessage (which runs isPeerMessage) before isClientPeerSendMessage, so HTTP batch was indeed the only unguarded path. ServerPeer.message uses message.id directly and echoes it into the response — confirming this is the right choke point. The oRPC BatchLinkPlugin sends base36 string ids and excludes Blob/FormData/ReadableStream bodies from batching, so the documented behavior change does not affect oRPC clients. Ran pnpm vitest run packages/server/src/plugins/batch.test.ts — 59 passed.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Move the duplicated batch validation into isClientPeerSendMessages. Its callback takes unknown, so tsc now rejects calling isClientPeerSendMessage without isPeerMessage first. Share the invalid-id test cases between the POST and GET tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6CM5zP1QBSVRmibkRqzK3
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Shared batch guard — the duplicated inline HTTP batch checks in
packages/server/src/plugins/batch.tsare extracted intoisClientPeerSendMessages, which runsisPeerMessage(m) && isClientPeerSendMessage(m)over the array. The rewrite is semantically identical to the previous!Array.isArray(...) || some(m => !isPeerMessage(m) || !isClientPeerSendMessage(m)), and the returned type predicate now narrowsmessagesso the assignment needs no cast. - Test DRY-up — the two duplicated invalid-id arrays in
batch.test.tscollapse into one sharedINVALID_MESSAGE_IDSconstant that feeds both the POST and GETit.eachcases.
Behavior-preserving refactor with no new behavioral surface. pnpm vitest run packages/server/src/plugins/batch.test.ts → 59 passed.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
JSON.parse returns any, so the GET branch could assign the parsed data to messages without the guard. Typing it as unknown, like the POST body already is, makes tsc require the guard on both branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6CM5zP1QBSVRmibkRqzK3
Dismissing prior approval to re-evaluate caa4ebd
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
orpc | 1192899 | Commit Preview URL Branch Preview URL |
Oct 22 2026, 01:34 PM |
BatchLinkPlugin validated array batch responses with isServerPeerSendMessage alone, which never checks id or binary. A message with a non-string id was silently dropped and the call failed with "Batch response is incomplete." instead of "Invalid batch response format.", and a JSON object in binary resolved the call with a "[object Object]" file. Require isPeerMessage as well, like the server and the length-prefixed paths already do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6CM5zP1QBSVRmibkRqzK3
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the client-side half of the batch validation fix (commit 1192899f), added since the prior pullfrog review.
- Client array-response guard —
packages/client/src/plugins/batch.tsnow requiresisPeerMessage(v) && isServerPeerSendMessage(v)for every element of a JSON array batch response, so a response carrying a non-stringidor a non-Uint8Array/Blobbinaryfails withInvalid batch response format.instead of being silently dropped or resolved as"[object Object]". - Regression tests —
packages/client/src/plugins/batch.test.tsaddsit.eachcases fortrue/{}/[]/null/0/missing ids plus a non-binarybinarycase, each asserting the batch rejects withInvalid batch response format. - Fixture update —
packages/node/src/batch-response-compression-handler-plugin.test.tsemitsString(index)ids.
I traced the change against @standard-server/peer: isPeerMessage is shallow (typeof id === 'string', typeof kind === 'string', binary undefined/Uint8Array/Blob), so it closes the id and binary gaps without touching the length-prefixed Blob/ReadableStream paths, which already gate on decodePeerMessage. Failing the whole batch on any malformed message is consistent with those paths. pnpm vitest run packages/client/src/plugins/batch.test.ts → 64 passed; pnpm type:check → clean.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Bug
Both batch plugins checked untrusted JSON batch messages with the
@standard-server/peerguardsisClientPeerSendMessage/isServerPeerSendMessageand nothing else. Those guards assumeisPeerMessagehas already run, so they never checkidorbinary.Server:
BatchHandlerPlugin(GET?data=and POST body)true,{},[],null, numbers, or a missing id were accepted. The procedure ran and the bad id was echoed back in the 207 response.JSON.stringifythrewRangeError: Maximum call stack size exceeded. That happens outside every interceptor, so no error handling or logging plugin can catch it.Client:
BatchLinkPlugin(JSON array responses)This side gets its input from the server or anything in between, not from untrusted clients, so the impact is lower. A malformed response still failed in the wrong way:
Batch response is incomplete.instead ofInvalid batch response format.binarydidn't fail at all. The call resolved with a 15-byte file containing"[object Object]".The WebSocket, crossws and message-port adapters, and the client's length-prefixed batch decoding, already reject these messages. They either call
isPeerMessagedirectly or go throughdecodePeerMessage, which calls it. Only the two JSON batch paths were missing the check.This is the same class of bug as trpc/trpc#7477, where
assertIsRequestIdaccepted invalid ids. That fix is what led us to look here.Fix
packages/server/src/plugins/batch.ts: both branches now call a newisClientPeerSendMessageshelper, which requiresisPeerMessage(m) && isClientPeerSendMessage(m)for every message. Invalid batches get a 400 before any procedure runs.packages/client/src/plugins/batch.ts: the array-response check becomesisPeerMessage(v) && isServerPeerSendMessage(v).(m: unknown), so tsc rejects a call toisClient/ServerPeerSendMessage(declared to take aPeerMessage) that isn't preceded byisPeerMessage.datais typedunknown, so it can't be assigned tomessageswithout the guard.JSON.parsereturnsany.binaryin array batch responses. These fail onmain.Behaviour change
Hand-built HTTP batch clients that send numeric ids (or any non-string id) now get a
400instead of a 207. The oRPC client always sends string ids, so oRPC clients are unaffected.Verification
pnpm vitest run: 287 files passed, 7 skipped (3977 tests)pnpm type:check: cleanpnpm lint: cleanFollow-up (not in this PR)
The root cause sits upstream in
@standard-server/peer: middleapi/standard-server#172 makes the combined guards takeunknownand runisPeerMessagethemselves. This PR doesn't depend on it. The explicitisPeerMessagecalls here stay correct either way, and they can be dropped once that change is released and the dependency is bumped.🤖 Generated with Claude Code
https://claude.ai/code/session_01X6CM5zP1QBSVRmibkRqzK3