Skip to content

fix(rpc): reject batch messages with non-string ids - #2304

Merged
dinwwwh merged 4 commits into
mainfrom
claude/trusting-franklin-plx5vm
Oct 22, 2026
Merged

dinwwwh merged 4 commits into
mainfrom
claude/trusting-franklin-plx5vm

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Bug

Both batch plugins checked untrusted JSON batch messages with the @standard-server/peer guards isClientPeerSendMessage / isServerPeerSendMessage and nothing else. Those guards assume isPeerMessage has already run, so they never check id or binary.

Server: BatchHandlerPlugin (GET ?data= and POST body)

  • Ids like true, {}, [], null, numbers, or a missing id were accepted. The procedure ran and the bad id was echoed back in the 207 response.
  • A deeply nested array id (~10k levels, a ~20KB body) passed validation. When the adapter serialized the response, JSON.stringify threw RangeError: 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:

  • 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.
  • A JSON object in binary didn'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 isPeerMessage directly or go through decodePeerMessage, 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 assertIsRequestId accepted invalid ids. That fix is what led us to look here.

Fix

  • packages/server/src/plugins/batch.ts: both branches now call a new isClientPeerSendMessages helper, which requires isPeerMessage(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 becomes isPeerMessage(v) && isServerPeerSendMessage(v).
  • The type checker now enforces the guard order:
    • The per-message callbacks are typed (m: unknown), so tsc rejects a call to isClient/ServerPeerSendMessage (declared to take a PeerMessage) that isn't preceded by isPeerMessage.
    • The parsed GET data is typed unknown, so it can't be assigned to messages without the guard.
    • The original bug got past tsc because JSON.parse returns any.
  • Tests:
    • Server: regression tests for invalid ids (POST and GET) and for the deeply nested id.
    • Client: regression tests for invalid ids and an invalid binary in array batch responses. These fail on main.
    • Existing tests that built batches by hand switched from numeric to string ids.

Behaviour change

Hand-built HTTP batch clients that send numeric ids (or any non-string id) now get a 400 instead of a 207. The oRPC client always sends string ids, so oRPC clients are unaffected.

Verification

  • Full root pnpm vitest run: 287 files passed, 7 skipped (3977 tests)
  • pnpm type:check: clean
  • pnpm lint: clean

Follow-up (not in this PR)

The root cause sits upstream in @standard-server/peer: middleapi/standard-server#172 makes the combined guards take unknown and run isPeerMessage themselves. This PR doesn't depend on it. The explicit isPeerMessage calls 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

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
@macroscopeapp

macroscopeapp Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Approvability

Verdict: Approved at 1192899

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.

@pkg-pr-new

pkg-pr-new Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2304

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2304

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2304

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2304

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2304

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2304

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2304

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2304

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2304

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2304

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2304

@orpc/mcp

npm i https://pkg.pr.new/@orpc/mcp@2304

@orpc/msw

npm i https://pkg.pr.new/@orpc/msw@2304

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2304

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2304

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2304

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2304

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2304

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2304

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2304

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2304

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2304

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2304

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2304

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2304

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2304

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2304

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2304

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2304

commit: 1192899

@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

1 warning

⚠️ 5 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/trusting-franklin-plx5vm (1192899) with main (947c60a)2

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (4cac2a0) during the generation of this report, so 947c60a was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • batch.ts validation — both the GET (?data=) and POST body paths now reject !isPeerMessage(m) || !isClientPeerSendMessage(m). isPeerMessage is shallow (typeof id === 'string', typeof kind === 'string', binary undefined/Uint8Array/Blob), so a nested-array id fails fast instead of overflowing JSON.stringify during response serialization.
  • Regression tests — batch.test.ts uses string ids throughout and adds POST + GET cases for true/{}/[]/null/0/missing ids and a 10k-deep nested array id, each asserting 400 and zero handler calls.
  • Fixture updates — get-method-csrf-protection.test.ts and node/batch-response-compression-handler-plugin.test.ts switch 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.

Pullfrog  | View workflow run | Using 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
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 22, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Shared batch guard — the duplicated inline HTTP batch checks in packages/server/src/plugins/batch.ts are extracted into isClientPeerSendMessages, which runs isPeerMessage(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 narrows messages so the assignment needs no cast.
  • Test DRY-up — the two duplicated invalid-id arrays in batch.test.ts collapse into one shared INVALID_MESSAGE_IDS constant that feeds both the POST and GET it.each cases.

Behavior-preserving refactor with no new behavioral surface. pnpm vitest run packages/server/src/plugins/batch.test.ts → 59 passed.

Pullfrog  | View workflow run | Using 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
@macroscopeapp
macroscopeapp Bot dismissed their stale review October 22, 2026 13:24

Dismissing prior approval to re-evaluate caa4ebd

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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
@dinwwwh dinwwwh changed the title fix(server): reject batch messages with non-string ids fix(rpc): reject batch messages with non-string ids Oct 22, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.ts now requires isPeerMessage(v) && isServerPeerSendMessage(v) for every element of a JSON array batch response, so a response carrying a non-string id or a non-Uint8Array/Blob binary fails with Invalid batch response format. instead of being silently dropped or resolved as "[object Object]".
  • Regression tests — packages/client/src/plugins/batch.test.ts adds it.each cases for true/{}/[]/null/0/missing ids plus a non-binary binary case, each asserting the batch rejects with Invalid batch response format.
  • Fixture update — packages/node/src/batch-response-compression-handler-plugin.test.ts emits String(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.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit ce9e0f6 into main Oct 22, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants