Repository navigation
Conversation
isClientPeerSendMessage and isServerPeerSendMessage only checked kind and json, assuming isPeerMessage had already run, so a caller validating raw input with them alone accepted a non-string id or a non-binary binary, and null threw a TypeError. They now take unknown and run isPeerMessage first, so they are safe on untrusted input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6CM5zP1QBSVRmibkRqzK3
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This PR hardens the client/server peer send-message guards by validating the common message shape before subtype checks, while preserving behavior for valid messages. It adds focused tests for malformed inputs and has no broader runtime, schema, deployment, or infrastructure changes. You can add or adjust custom eligibility rules. Learn more. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Combined guards now validate the envelope first —
isClientPeerSendMessageandisServerPeerSendMessagetakeunknownand callisPeerMessage(maybe)before the per-kind||chain, so raw input with a non-stringid, a non-binarybinary, a non-object, ornullnow returnsfalseinstead of wrongly accepting or throwing. - Per-kind guards left as-is —
isPeerRequestMessage,isPeerResponseMessage, etc. stay typedPeerMessage; they run after the narrowing and their signatures already state the precondition. - Regression tests added —
validators.test.tsgains anit.eachblock per combined guard covering non-string id, stringbinary,null, and array.
I confirmed the new cases are discriminating: reverting only validators.ts to base 6f1fc5a fails 6 of the 8 (both guards wrongly accept a non-string id and a string binary, and both throw TypeError on null), matching the PR description. The parameter widening PeerMessage → unknown is non-breaking, and the only in-repo callers pass decodePeerMessage(...).message, which already runs isPeerMessage, so behavior is unchanged for real messages.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
## 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.com/claude-code)
https://claude.ai/code/session_01X6CM5zP1QBSVRmibkRqzK3
---------
Co-authored-by: Claude <noreply@anthropic.com>

Problem
isClientPeerSendMessageandisServerPeerSendMessagewere typed(maybe: PeerMessage)and only checkedkindandjson. They assumed the caller had already runisPeerMessage, which checks:idis a stringkindis a stringbinaryisundefined, aUint8Arrayor aBlobNothing at runtime enforces that ordering. TypeScript doesn't catch it either when the input is
any, asJSON.parseoutput is. A caller that validates raw input with one of these guards alone:idistrue,{},[],null, a number, or missing;binarythat isn't binary, such as a string or a plain object;TypeError: Cannot read properties of null (reading 'kind')onnull, instead offalse.This happened in oRPC's HTTP batch plugins, fixed downstream in middleapi/orpc#2304:
JSON.stringifythrowRangeError: Maximum call stack size exceededoutside every interceptor.binaryresolved the call with a"[object Object]"file.The same mistake is easy to repeat in any new caller that handles raw input.
Fix
Both combined guards now take
unknownand runisPeerMessagefirst. They are safe to call directly on untrusted input, and they behave the same when given a realPeerMessage.The per-kind guards (
isPeerRequestMessage,isPeerResponseMessage, …) stay typedPeerMessage. They are the building blocks the combined guards call after narrowing, and their signature already states what they expect.Compatibility
PeerMessagetounknownis non-breaking for callers. Existing code that passes aPeerMessage, such as the output ofdecodePeerMessage, compiles and narrows as before.isPeerMessagerejects now returnfalse. Before, they could returntrue, or throw fornull. The internal client and server code doesn't call these guards.decodePeerMessagealready runsisPeerMessage, so for messages that come from it the extra check costs a few property reads.Tests
validators.test.tsadds cases to both guards for a non-string id, a stringbinary,null, and an array. Six of the eight fail onmain: two are wrongly accepted and one throws aTypeError, for each guard.pnpm test: 1382 vitest tests and 52 bun tests passedpnpm run check: sherif, oxlint, oxfmt and tsc all passGenerated by Claude Code