Skip to content

Commit 1acbd8b

Browse files
authored
Harden JSON-RPC message classification (#6759)
1 parent a4757f1 commit 1acbd8b

5 files changed

Lines changed: 91 additions & 29 deletions

File tree

.changeset/tough-taxis-own.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
Harden JSON-RPC wire message classification against inherited properties.

packages/effect/src/unstable/rpc/RpcClient.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,11 +1049,12 @@ export const makeProtocolSocket = (options?: {
10491049
pinger.onPong()
10501050
return Effect.void
10511051
}
1052-
if ("requestId" in response) {
1053-
const clientId = requestClientMap.get(response.requestId)
1052+
if (Object.hasOwn(response, "requestId")) {
1053+
const requestId = (response as FromServerEncoded & { readonly requestId: string | number }).requestId
1054+
const clientId = requestClientMap.get(requestId)
10541055
if (clientId !== undefined) {
10551056
if (response._tag === "Exit") {
1056-
requestClientMap.delete(response.requestId)
1057+
requestClientMap.delete(requestId)
10571058
}
10581059
return writeResponse(clientId, response)
10591060
}

packages/effect/src/unstable/rpc/RpcSerialization.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,13 @@ function decodeJsonRpcRaw(
212212
}
213213

214214
function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded {
215-
if ("method" in decoded) {
216-
if (Predicate.isNullish(decoded.id) && decoded.method.startsWith("@effect/rpc/")) {
217-
const tag = decoded.method.slice("@effect/rpc/".length) as
215+
if (Object.hasOwn(decoded, "method")) {
216+
const request = decoded as JsonRpcRequest
217+
if (Predicate.isNullish(request.id) && request.method.startsWith("@effect/rpc/")) {
218+
const tag = request.method.slice("@effect/rpc/".length) as
218219
| RpcMessage.FromServerEncoded["_tag"]
219220
| Exclude<RpcMessage.FromClientEncoded["_tag"], "Request">
220-
const requestId = (decoded as any).params?.requestId
221+
const requestId = (request as any).params?.requestId
221222
return requestId ?
222223
{
223224
_tag: tag,
@@ -227,46 +228,49 @@ function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEnc
227228
}
228229
return {
229230
_tag: "Request",
230-
id: decoded.id ?? "",
231-
tag: decoded.method,
232-
payload: decoded.params ?? null,
233-
headers: decoded.headers ?? [],
234-
...(decoded.spanId ?
231+
id: request.id ?? "",
232+
tag: request.method,
233+
payload: request.params ?? null,
234+
headers: request.headers ?? [],
235+
...(request.spanId ?
235236
{
236-
traceId: decoded.traceId,
237-
spanId: decoded.spanId!,
238-
sampled: decoded.sampled!
237+
traceId: request.traceId,
238+
spanId: request.spanId!,
239+
sampled: request.sampled!
239240
} :
240241
{})
241242
}
242-
} else if (decoded.error && decoded.error._tag === "Defect") {
243+
}
244+
const response = decoded as JsonRpcResponse
245+
const hasError = Object.hasOwn(response, "error")
246+
if (hasError && response.error && response.error._tag === "Defect") {
243247
return {
244248
_tag: "Defect",
245-
defect: decoded.error.data
249+
defect: response.error.data
246250
}
247-
} else if (decoded.chunk === true) {
251+
} else if (Object.hasOwn(response, "chunk") && response.chunk === true) {
248252
return {
249253
_tag: "Chunk",
250-
requestId: decoded.id ?? "",
251-
values: decoded.result as any
254+
requestId: response.id ?? "",
255+
values: response.result as any
252256
}
253257
}
254258
return {
255259
_tag: "Exit",
256-
requestId: decoded.id ?? "",
257-
exit: decoded.error != null ?
260+
requestId: response.id ?? "",
261+
exit: hasError && response.error != null ?
258262
{
259263
_tag: "Failure",
260-
cause: decoded.error._tag === "Cause" ?
261-
decoded.error.data as any :
264+
cause: response.error._tag === "Cause" ?
265+
response.error.data as any :
262266
[{
263267
_tag: "Die",
264-
defect: decoded.error
268+
defect: response.error
265269
}]
266270
} :
267271
{
268272
_tag: "Success",
269-
value: decoded.result
273+
value: response.result
270274
}
271275
}
272276
}

packages/effect/src/unstable/rpc/RpcServer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { reportCauseUnsafe } from "../../internal/effect.ts"
2222
import * as Latch from "../../Latch.ts"
2323
import * as Layer from "../../Layer.ts"
2424
import * as Option from "../../Option.ts"
25-
import * as Predicate from "../../Predicate.ts"
2625
import * as Pull from "../../Pull.ts"
2726
import * as Queue from "../../Queue.ts"
2827
import * as Schedule from "../../Schedule.ts"
@@ -688,7 +687,7 @@ export const make: <Rpcs extends Rpc.Any>(
688687

689688
switch (request._tag) {
690689
case "Request": {
691-
const tag = Predicate.hasProperty(request, "tag") ? (request.tag as string) : ""
690+
const tag = Object.hasOwn(request, "tag") ? (request.tag as string) : ""
692691
let requestId: RequestId
693692
switch (typeof request.id) {
694693
case "number":

packages/effect/test/rpc/RpcSerialization.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { assert, describe, it } from "@effect/vitest"
1+
import { afterEach, assert, describe, it } from "@effect/vitest"
22
import { RpcSerialization } from "effect/unstable/rpc"
33

44
const responseExitSuccess = (requestId: string | number, value: unknown) => ({
@@ -10,7 +10,60 @@ const responseExitSuccess = (requestId: string | number, value: unknown) => ({
1010
}
1111
})
1212

13+
const objectPrototype = Object.prototype as Record<string, unknown>
14+
15+
const polluteObjectPrototype = (key: string, value: unknown) => {
16+
Object.defineProperty(objectPrototype, key, {
17+
configurable: true,
18+
value
19+
})
20+
}
21+
22+
const decodeJsonRpcSuccess = () =>
23+
RpcSerialization.jsonRpc().makeUnsafe().decode("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"ok\"}")
24+
25+
const expectedJsonRpcSuccess = [{
26+
_tag: "Exit",
27+
requestId: 1,
28+
exit: {
29+
_tag: "Success",
30+
value: "ok"
31+
}
32+
}]
33+
1334
describe("RpcSerialization", () => {
35+
describe.sequential("jsonRpc inherited properties", () => {
36+
afterEach(() => {
37+
delete objectPrototype["method"]
38+
delete objectPrototype["error"]
39+
delete objectPrototype["chunk"]
40+
})
41+
42+
it("decodes a success response with a clean prototype", () => {
43+
assert.deepStrictEqual(decodeJsonRpcSuccess(), expectedJsonRpcSuccess)
44+
})
45+
46+
it("ignores an inherited method", () => {
47+
polluteObjectPrototype("method", "attacker.evil")
48+
assert.deepStrictEqual(decodeJsonRpcSuccess(), expectedJsonRpcSuccess)
49+
})
50+
51+
it("ignores an inherited defect error", () => {
52+
polluteObjectPrototype("error", { _tag: "Defect", data: "pwn" })
53+
assert.deepStrictEqual(decodeJsonRpcSuccess(), expectedJsonRpcSuccess)
54+
})
55+
56+
it("ignores an inherited chunk marker", () => {
57+
polluteObjectPrototype("chunk", true)
58+
assert.deepStrictEqual(decodeJsonRpcSuccess(), expectedJsonRpcSuccess)
59+
})
60+
61+
it("ignores an inherited exit error", () => {
62+
polluteObjectPrototype("error", { _tag: "Cause", data: [] })
63+
assert.deepStrictEqual(decodeJsonRpcSuccess(), expectedJsonRpcSuccess)
64+
})
65+
})
66+
1467
it("json decode keeps array payloads flat", () => {
1568
const parser = RpcSerialization.json.makeUnsafe()
1669
const decoded = parser.decode("[1,2,3]")

0 commit comments

Comments
 (0)