Skip to content

Commit 77b8daa

Browse files
authored
test: add unit coverage for PostMessageTransport source validation (#536)
* test: add unit coverage for PostMessageTransport source validation PostMessageTransport is the iframe↔host communication layer and contains the primary security boundary (event.source validation). Previously it had zero unit coverage — only exercised indirectly via E2E tests, where the cross-app injection test is a no-op due to cross-origin contentDocument restrictions. Covers 18 test cases across: - Source validation: trusted source delivers, untrusted/null sources dropped silently (no onerror DoS vector), transport survives rejection - Message format: valid requests/notifications delivered; ambient non-JSON-RPC traffic (devtools, extensions) silently ignored; malformed jsonrpc:'2.0' payloads surface via onerror - send(): posts to configured target with '*' targetOrigin - Lifecycle: start() registers listener, close() removes it and fires onclose, post-close messages ignored, multi-transport isolation Uses a minimal in-memory window stub rather than jsdom/happy-dom — consistent with the existing bun test setup which has no DOM configured. Note: the transport validates event.source (window identity), NOT event.origin. send() posts with '*'. Origin is intentionally not part of this transport's trust model. * fix: resolve TS diagnostics in message-transport tests - TS2352: cast `{ id: string }` through `unknown` before `MessageEventSource` (lines 366, 370) — no structural overlap - TS80007: drop ineffective `await` on bun's `.resolves` matcher (line 355) — it returns void, not Promise<void> * docs: clarify origin validation lives in sandbox proxy, not transport
1 parent 64b4fa1 commit 77b8daa

1 file changed

Lines changed: 390 additions & 0 deletions

File tree

src/message-transport.test.ts

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
2+
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
3+
4+
import { PostMessageTransport } from "./message-transport";
5+
6+
/**
7+
* Minimal `window` stub for bun's DOM-less test environment.
8+
*
9+
* Captures listeners registered via `addEventListener` so tests can dispatch
10+
* fake `MessageEvent`-like objects directly. We deliberately avoid pulling in
11+
* jsdom/happy-dom — this file tests the transport's contract, not the browser.
12+
*/
13+
type Listener = (event: MessageEvent) => void;
14+
15+
function createFakeWindow() {
16+
const listeners = new Map<string, Set<Listener>>();
17+
return {
18+
addEventListener(type: string, listener: Listener) {
19+
if (!listeners.has(type)) listeners.set(type, new Set());
20+
listeners.get(type)!.add(listener);
21+
},
22+
removeEventListener(type: string, listener: Listener) {
23+
listeners.get(type)?.delete(listener);
24+
},
25+
/** Test helper: invoke all listeners for `type` with the given event. */
26+
dispatch(type: string, event: unknown) {
27+
listeners.get(type)?.forEach((l) => l(event as MessageEvent));
28+
},
29+
/** Test helper: how many listeners are registered for `type`? */
30+
listenerCount(type: string) {
31+
return listeners.get(type)?.size ?? 0;
32+
},
33+
};
34+
}
35+
36+
/**
37+
* A valid JSON-RPC request payload. Used by most happy-path tests.
38+
* Kept deliberately minimal — we care that *a* valid message is delivered,
39+
* not about exercising every JSON-RPC shape (that's the schema's job).
40+
*/
41+
const validRequest: JSONRPCMessage = {
42+
jsonrpc: "2.0",
43+
id: 1,
44+
method: "ping",
45+
};
46+
47+
describe("PostMessageTransport", () => {
48+
let fakeWindow: ReturnType<typeof createFakeWindow>;
49+
let trustedSource: object;
50+
let untrustedSource: object;
51+
52+
/** Stand-in for the target window (e.g. window.parent or iframe.contentWindow). */
53+
let targetPostMessage: ReturnType<typeof mock>;
54+
let eventTarget: { postMessage: typeof targetPostMessage };
55+
56+
// The transport logs liberally at debug/error level. Silence during tests
57+
// so failures are readable; restore afterwards so we don't leak state.
58+
let restoreConsole: () => void;
59+
60+
beforeEach(() => {
61+
fakeWindow = createFakeWindow();
62+
(globalThis as { window?: unknown }).window = fakeWindow;
63+
64+
// Distinct object identities — the transport compares with `!==`.
65+
trustedSource = { id: "trusted" };
66+
untrustedSource = { id: "untrusted" };
67+
68+
targetPostMessage = mock(() => {});
69+
eventTarget = { postMessage: targetPostMessage };
70+
71+
const origDebug = console.debug;
72+
const origError = console.error;
73+
console.debug = () => {};
74+
console.error = () => {};
75+
restoreConsole = () => {
76+
console.debug = origDebug;
77+
console.error = origError;
78+
};
79+
});
80+
81+
afterEach(() => {
82+
restoreConsole();
83+
delete (globalThis as { window?: unknown }).window;
84+
});
85+
86+
/** Create and start a transport wired to the shared fakes. */
87+
async function createStartedTransport() {
88+
const transport = new PostMessageTransport(
89+
eventTarget as unknown as Window,
90+
trustedSource as MessageEventSource,
91+
);
92+
await transport.start();
93+
return transport;
94+
}
95+
96+
// ==========================================================================
97+
// Source validation — the security boundary at this layer.
98+
// The transport validates `event.source` (window identity), not `event.origin`.
99+
// Origin checks live in the sandbox proxy relay between the two endpoints
100+
// (see examples/basic-host/src/sandbox.ts). Here, source===contentWindow is
101+
// the narrower check; the app side can't know its sandbox's origin anyway.
102+
// ==========================================================================
103+
describe("source validation", () => {
104+
it("delivers messages from the configured eventSource", async () => {
105+
const transport = await createStartedTransport();
106+
const received: JSONRPCMessage[] = [];
107+
transport.onmessage = (msg) => received.push(msg);
108+
109+
fakeWindow.dispatch("message", {
110+
source: trustedSource,
111+
data: validRequest,
112+
});
113+
114+
expect(received).toEqual([validRequest]);
115+
});
116+
117+
it("drops messages from a different source", async () => {
118+
const transport = await createStartedTransport();
119+
const received: JSONRPCMessage[] = [];
120+
const errors: Error[] = [];
121+
transport.onmessage = (msg) => received.push(msg);
122+
transport.onerror = (err) => errors.push(err);
123+
124+
fakeWindow.dispatch("message", {
125+
source: untrustedSource,
126+
data: validRequest,
127+
});
128+
129+
// The message is silently dropped — neither delivered nor surfaced as
130+
// an error. An attacker flooding us with forged messages should not be
131+
// able to DoS the error handler.
132+
expect(received).toEqual([]);
133+
expect(errors).toEqual([]);
134+
});
135+
136+
it("drops messages with a null source", async () => {
137+
// Some browser-injected messages (extensions, devtools) arrive with
138+
// source === null. These must not reach the protocol layer.
139+
const transport = await createStartedTransport();
140+
const received: JSONRPCMessage[] = [];
141+
transport.onmessage = (msg) => received.push(msg);
142+
143+
fakeWindow.dispatch("message", {
144+
source: null,
145+
data: validRequest,
146+
});
147+
148+
expect(received).toEqual([]);
149+
});
150+
151+
it("continues delivering trusted messages after dropping an untrusted one", async () => {
152+
// Regression guard: a rejected message must not break the listener or
153+
// close the transport.
154+
const transport = await createStartedTransport();
155+
const received: JSONRPCMessage[] = [];
156+
transport.onmessage = (msg) => received.push(msg);
157+
158+
fakeWindow.dispatch("message", {
159+
source: untrustedSource,
160+
data: validRequest,
161+
});
162+
fakeWindow.dispatch("message", {
163+
source: trustedSource,
164+
data: validRequest,
165+
});
166+
167+
expect(received).toEqual([validRequest]);
168+
});
169+
});
170+
171+
// ==========================================================================
172+
// Message format validation.
173+
// Three paths: valid → onmessage, non-JSON-RPC → silent, malformed → onerror.
174+
// ==========================================================================
175+
describe("message format validation", () => {
176+
it("delivers valid JSON-RPC requests via onmessage", async () => {
177+
const transport = await createStartedTransport();
178+
const received: JSONRPCMessage[] = [];
179+
transport.onmessage = (msg) => received.push(msg);
180+
181+
fakeWindow.dispatch("message", {
182+
source: trustedSource,
183+
data: { jsonrpc: "2.0", id: 42, method: "tools/call" },
184+
});
185+
186+
expect(received).toHaveLength(1);
187+
expect(received[0]).toMatchObject({ id: 42, method: "tools/call" });
188+
});
189+
190+
it("delivers valid JSON-RPC notifications via onmessage", async () => {
191+
const transport = await createStartedTransport();
192+
const received: JSONRPCMessage[] = [];
193+
transport.onmessage = (msg) => received.push(msg);
194+
195+
fakeWindow.dispatch("message", {
196+
source: trustedSource,
197+
data: { jsonrpc: "2.0", method: "notifications/initialized" },
198+
});
199+
200+
expect(received).toHaveLength(1);
201+
expect(received[0]).toMatchObject({
202+
method: "notifications/initialized",
203+
});
204+
});
205+
206+
it("silently ignores non-JSON-RPC payloads", async () => {
207+
// Real iframes receive all sorts of ambient postMessage traffic:
208+
// React DevTools, browser extensions, ad frames. These must not crash
209+
// the transport or surface as protocol errors.
210+
const transport = await createStartedTransport();
211+
const received: JSONRPCMessage[] = [];
212+
const errors: Error[] = [];
213+
transport.onmessage = (msg) => received.push(msg);
214+
transport.onerror = (err) => errors.push(err);
215+
216+
fakeWindow.dispatch("message", {
217+
source: trustedSource,
218+
data: { type: "react-devtools-hook", payload: {} },
219+
});
220+
fakeWindow.dispatch("message", { source: trustedSource, data: "hello" });
221+
fakeWindow.dispatch("message", { source: trustedSource, data: null });
222+
fakeWindow.dispatch("message", { source: trustedSource, data: 42 });
223+
224+
expect(received).toEqual([]);
225+
expect(errors).toEqual([]);
226+
});
227+
228+
it("calls onerror for malformed messages claiming to be JSON-RPC", async () => {
229+
// `jsonrpc: "2.0"` but missing required fields — this IS a protocol
230+
// violation worth surfacing, unlike ambient noise.
231+
const transport = await createStartedTransport();
232+
const received: JSONRPCMessage[] = [];
233+
const errors: Error[] = [];
234+
transport.onmessage = (msg) => received.push(msg);
235+
transport.onerror = (err) => errors.push(err);
236+
237+
fakeWindow.dispatch("message", {
238+
source: trustedSource,
239+
data: { jsonrpc: "2.0" }, // no id/method/result — invalid
240+
});
241+
242+
expect(received).toEqual([]);
243+
expect(errors).toHaveLength(1);
244+
expect(errors[0]).toBeInstanceOf(Error);
245+
});
246+
247+
it("does not throw when onerror is unset and a malformed message arrives", async () => {
248+
const transport = await createStartedTransport();
249+
transport.onmessage = () => {};
250+
// onerror deliberately left unset
251+
252+
expect(() =>
253+
fakeWindow.dispatch("message", {
254+
source: trustedSource,
255+
data: { jsonrpc: "2.0" },
256+
}),
257+
).not.toThrow();
258+
});
259+
260+
it("does not throw when onmessage is unset and a valid message arrives", async () => {
261+
await createStartedTransport();
262+
// onmessage deliberately left unset — may happen briefly during wiring
263+
264+
expect(() =>
265+
fakeWindow.dispatch("message", {
266+
source: trustedSource,
267+
data: validRequest,
268+
}),
269+
).not.toThrow();
270+
});
271+
});
272+
273+
// ==========================================================================
274+
// send()
275+
// ==========================================================================
276+
describe("send()", () => {
277+
it("posts the message to the configured eventTarget with '*' origin", async () => {
278+
const transport = await createStartedTransport();
279+
280+
const msg: JSONRPCMessage = {
281+
jsonrpc: "2.0",
282+
id: 7,
283+
method: "ui/initialize",
284+
};
285+
await transport.send(msg);
286+
287+
expect(targetPostMessage).toHaveBeenCalledTimes(1);
288+
expect(targetPostMessage).toHaveBeenCalledWith(msg, "*");
289+
});
290+
291+
it("posts multiple messages in order", async () => {
292+
const transport = await createStartedTransport();
293+
294+
const a: JSONRPCMessage = { jsonrpc: "2.0", id: 1, method: "a" };
295+
const b: JSONRPCMessage = { jsonrpc: "2.0", id: 2, method: "b" };
296+
await transport.send(a);
297+
await transport.send(b);
298+
299+
expect(targetPostMessage).toHaveBeenCalledTimes(2);
300+
expect(targetPostMessage.mock.calls[0][0]).toEqual(a);
301+
expect(targetPostMessage.mock.calls[1][0]).toEqual(b);
302+
});
303+
});
304+
305+
// ==========================================================================
306+
// Lifecycle: start() / close()
307+
// ==========================================================================
308+
describe("lifecycle", () => {
309+
it("start() registers a message listener on window", async () => {
310+
const transport = new PostMessageTransport(
311+
eventTarget as unknown as Window,
312+
trustedSource as MessageEventSource,
313+
);
314+
315+
expect(fakeWindow.listenerCount("message")).toBe(0);
316+
await transport.start();
317+
expect(fakeWindow.listenerCount("message")).toBe(1);
318+
});
319+
320+
it("close() removes the message listener", async () => {
321+
const transport = await createStartedTransport();
322+
323+
expect(fakeWindow.listenerCount("message")).toBe(1);
324+
await transport.close();
325+
expect(fakeWindow.listenerCount("message")).toBe(0);
326+
});
327+
328+
it("messages dispatched after close() are not delivered", async () => {
329+
const transport = await createStartedTransport();
330+
const received: JSONRPCMessage[] = [];
331+
transport.onmessage = (msg) => received.push(msg);
332+
333+
await transport.close();
334+
fakeWindow.dispatch("message", {
335+
source: trustedSource,
336+
data: validRequest,
337+
});
338+
339+
expect(received).toEqual([]);
340+
});
341+
342+
it("close() invokes onclose when set", async () => {
343+
const transport = await createStartedTransport();
344+
const onclose = mock(() => {});
345+
transport.onclose = onclose;
346+
347+
await transport.close();
348+
349+
expect(onclose).toHaveBeenCalledTimes(1);
350+
});
351+
352+
it("close() does not throw when onclose is unset", async () => {
353+
const transport = await createStartedTransport();
354+
// onclose deliberately left unset
355+
356+
expect(transport.close()).resolves.toBeUndefined();
357+
});
358+
359+
it("two transports listen independently", async () => {
360+
// Host scenario: multiple iframes, each with its own transport.
361+
// Each transport must only accept messages from its own iframe.
362+
const sourceA = { id: "iframe-a" };
363+
const sourceB = { id: "iframe-b" };
364+
365+
const transportA = new PostMessageTransport(
366+
eventTarget as unknown as Window,
367+
sourceA as unknown as MessageEventSource,
368+
);
369+
const transportB = new PostMessageTransport(
370+
eventTarget as unknown as Window,
371+
sourceB as unknown as MessageEventSource,
372+
);
373+
await transportA.start();
374+
await transportB.start();
375+
376+
const receivedA: JSONRPCMessage[] = [];
377+
const receivedB: JSONRPCMessage[] = [];
378+
transportA.onmessage = (msg) => receivedA.push(msg);
379+
transportB.onmessage = (msg) => receivedB.push(msg);
380+
381+
fakeWindow.dispatch("message", { source: sourceA, data: validRequest });
382+
383+
expect(receivedA).toHaveLength(1);
384+
expect(receivedB).toHaveLength(0);
385+
386+
await transportA.close();
387+
await transportB.close();
388+
});
389+
});
390+
});

0 commit comments

Comments
 (0)