From 92cf9dead087be2c77d88eb9beaf97739d456237 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Mon, 26 Oct 2026 07:20:44 -0500 Subject: [PATCH] fix: send only a byte view's own bytes over structured-clone transports At the structuredClonable and jsonCompatibleWithBytes levels, byte views were handed to the transport as-is. Structured clone copies a view's entire backing buffer, so a MessagePort peer received memory the sender never passed: the bytes around a subarray or typed array view, or Node's whole shared Buffer pool for any small Buffer. Copy out the view's range on the sending side whenever it doesn't cover its whole buffer. #201's copy runs on the receiving side, so it can't stop a peer from reading the raw message. --- .changeset/send-exact-byte-views.md | 5 +++++ __tests__/index.test.ts | 29 +++++++++++++++++++++++++++++ src/serialize.ts | 4 ++++ 3 files changed, 38 insertions(+) create mode 100644 .changeset/send-exact-byte-views.md diff --git a/.changeset/send-exact-byte-views.md b/.changeset/send-exact-byte-views.md new file mode 100644 index 00000000..331cb1f4 --- /dev/null +++ b/.changeset/send-exact-byte-views.md @@ -0,0 +1,5 @@ +--- +"capnweb": patch +--- + +Fix `MessagePort` sessions leaking a byte view's entire backing buffer instead of only the view's bytes. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e7fe3743..3286ad8e 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -2939,6 +2939,35 @@ describe("MessagePorts", () => { await expect(() => brokenPromise).rejects.toThrow( new Error("Peer closed MessagePort connection.")); }); + + it("sends only the bytes a view covers, not the rest of its backing buffer", async () => { + if (typeof Buffer === "undefined") return; // skip in browsers + + // Small Node Buffers are views into a shared pool of unrelated data, and structured clone + // copies a view's whole backing buffer. Buffer and Int16Array cover the unmarked and + // type-marked encode paths. + let backing = new Uint8Array(64).map((_, i) => i).buffer; + let buffer = Buffer.from(backing, 8, 4); + let int16 = new Int16Array(backing, 16, 2); + class BytesServer extends RpcTarget { + getBytes() { return [buffer, int16]; } + } + + let channel = new MessageChannel(); + // Inspect messages as they arrive: decoding rewrites them in place. + let crossedBufferSizes: number[] = []; + function collect(value: unknown) { + if (ArrayBuffer.isView(value)) crossedBufferSizes.push(value.buffer.byteLength); + else if (value instanceof Array) value.forEach(collect); + } + channel.port2.addEventListener("message", event => collect(event.data)); + + newMessagePortRpcSession(channel.port1, new BytesServer()); + using stub = newMessagePortRpcSession(channel.port2); + + expect(await stub.getBytes()).toStrictEqual([new Uint8Array(buffer), int16]); + expect(crossedBufferSizes).toStrictEqual([4, 4]); + }); }); // ======================================================================================= diff --git a/src/serialize.ts b/src/serialize.ts index 489cf711..c8f7118d 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -390,6 +390,10 @@ export class Devaluator { // At structuredClonable or jsonCompatibleWithBytes level, keep the bytes raw. if (this.encodingLevel === "structuredClonable" || this.encodingLevel === "jsonCompatibleWithBytes") { + // Copy partial views so the transport can't send the whole backing buffer. + if (bytes.byteLength !== bytes.buffer.byteLength) { + bytes = new Uint8Array(bytes); + } return alternateTypeName === undefined ? ["bytes", bytes] : ["bytes", bytes, alternateTypeName]; }