From 5f0ae1058a4072c4e8392b69bf7d16585f1b886b Mon Sep 17 00:00:00 2001 From: DriesOlbrechts Date: Fri, 3 Apr 2026 09:16:54 +0200 Subject: [PATCH 1/5] feat: add the option to set an allowed emitter origin to prevent untrusted origins from sending requests to the simulator --- src/channel/ChannelReceiver.ts | 23 ++++++--- src/kit/SimulatorManager.ts | 90 ++++++++++++++++++---------------- 2 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/channel/ChannelReceiver.ts b/src/channel/ChannelReceiver.ts index 3304cf5..3be8f40 100644 --- a/src/channel/ChannelReceiver.ts +++ b/src/channel/ChannelReceiver.ts @@ -7,20 +7,20 @@ import type { TransactionsHandlers, UnknownRequestMessage, UnknownResponseMessage, - UnknownTransaction} from "./types"; + UnknownTransaction, +} from "./types" import { InternalEmitterRequestType, - InternalReceiverRequestType + InternalReceiverRequestType, } from "./types" import { NotReadyError } from "./errors" import type { ChannelNetworkOptions, - PostRequestOptions} from "./ChannelNetwork"; -import { - ChannelNetwork + PostRequestOptions, } from "./ChannelNetwork" +import { ChannelNetwork } from "./ChannelNetwork" import { createErrorResponseMessage, createSuccessResponseMessage, @@ -30,12 +30,14 @@ import { export type ChannelReceiverOptions = { readyTimeout: number + allowedOrigin: string | null } export const channelReceiverDefaultOptions: ChannelReceiverOptions & Partial = { readyTimeout: 20000, requestIDPrefix: "receiver-", + allowedOrigin: null, } export type AllChannelReceiverOptions = ChannelReceiverOptions & @@ -83,7 +85,7 @@ export abstract class ChannelReceiver< >( request, (request) => { - window.parent.postMessage(request, "*") + window.parent.postMessage(request, this.options.allowedOrigin ?? "*") }, { timeout: this.options.readyTimeout, @@ -97,6 +99,14 @@ export abstract class ChannelReceiver< /** Handles public messages */ private _onPublicMessage(event: MessageEvent): void { + // Validate origin if allowedOrigin is configured + if ( + this.options.allowedOrigin && + this.options.allowedOrigin !== event.origin + ) { + return + } + try { const message = validateMessage(event.data) @@ -121,6 +131,7 @@ export abstract class ChannelReceiver< debug: this.options.debug, requestIDPrefix: this.options.requestIDPrefix, readyTimeout: this.options.readyTimeout, + allowedOrigin: this.options.allowedOrigin, } const response = createSuccessResponseMessage( diff --git a/src/kit/SimulatorManager.ts b/src/kit/SimulatorManager.ts index 55f6936..99acf4e 100644 --- a/src/kit/SimulatorManager.ts +++ b/src/kit/SimulatorManager.ts @@ -18,17 +18,20 @@ import { sliceSimulatorAccessedDirectly } from "./messages" type ManagerConstructorArgs = { slices?: SliceZone + allowedOrigin?: string } export class SimulatorManager { public state: State private _api: SimulatorAPI | null private _initialized: boolean + private _allowedOrigin: string | null constructor(args?: ManagerConstructorArgs) { this.state = new State(args) this._api = null this._initialized = false + this._allowedOrigin = args?.allowedOrigin ?? null } async init(): Promise { @@ -64,54 +67,59 @@ export class SimulatorManager { private async _initAPI(): Promise { // Register SimulatorAPI request handlers - this._api = new SimulatorAPI({ - [ClientRequestType.SetSliceZone]: (req, res) => { - this.state.setSliceZone(req.data) - - return res.success() - }, - [ClientRequestType.ScrollToSlice]: (req, res) => { - // Error if `sliceIndex` is invalid - if (req.data.sliceIndex < 0) { - return res.error("`sliceIndex` must be > 0", 400) - } else if (req.data.sliceIndex >= this.state.slices.length) { - return res.error( - `\`sliceIndex\` must be < ${this.state.slices.length} (\`\` current length)`, - 400, - ) - } + this._api = new SimulatorAPI( + { + [ClientRequestType.SetSliceZone]: (req, res) => { + this.state.setSliceZone(req.data) + + return res.success() + }, + [ClientRequestType.ScrollToSlice]: (req, res) => { + // Error if `sliceIndex` is invalid + if (req.data.sliceIndex < 0) { + return res.error("`sliceIndex` must be > 0", 400) + } else if (req.data.sliceIndex >= this.state.slices.length) { + return res.error( + `\`sliceIndex\` must be < ${this.state.slices.length} (\`\` current length)`, + 400, + ) + } - const $sliceZone = getSliceZoneDOM(this.state.slices.length) - if (!$sliceZone) { - return res.error("Failed to find ``", 500) - } + const $sliceZone = getSliceZoneDOM(this.state.slices.length) + if (!$sliceZone) { + return res.error("Failed to find ``", 500) + } - // Destroy existing active slice as we're about to scroll - this.state.activeSlice = null + // Destroy existing active slice as we're about to scroll + this.state.activeSlice = null - const $slice = $sliceZone.children[req.data.sliceIndex] - if (!$slice) { - return res.error( - `Failed fo find slice at index $\`{req.data.sliceIndex}\` in \`\``, - 500, - ) - } + const $slice = $sliceZone.children[req.data.sliceIndex] + if (!$slice) { + return res.error( + `Failed fo find slice at index $\`{req.data.sliceIndex}\` in \`\``, + 500, + ) + } - // Scroll to Slice - $slice.scrollIntoView({ - behavior: req.data.behavior, - block: req.data.block, - inline: req.data.inline, - }) + // Scroll to Slice + $slice.scrollIntoView({ + behavior: req.data.behavior, + block: req.data.block, + inline: req.data.inline, + }) - // Update active slice after scrolling - if (this._api?.options.activeSliceAPI) { - setTimeout(this.state.setActiveSlice, 750) - } + // Update active slice after scrolling + if (this._api?.options.activeSliceAPI) { + setTimeout(this.state.setActiveSlice, 750) + } - return res.success() + return res.success() + }, + }, + { + allowedOrigin: this._allowedOrigin, }, - }) + ) // Mark API as ready await this._api.ready() From 5b19550b638b8a9d6380dda686006c1e3f7f89bb Mon Sep 17 00:00:00 2001 From: DriesOlbrechts Date: Fri, 3 Apr 2026 09:47:14 +0200 Subject: [PATCH 2/5] chore: add tests --- test/SimulatorAPI.test.ts | 38 +++- ...nnel-ChannelReceiver-allowedOrigin.test.ts | 201 ++++++++++++++++++ 2 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 test/channel-ChannelReceiver-allowedOrigin.test.ts diff --git a/test/SimulatorAPI.test.ts b/test/SimulatorAPI.test.ts index ffd30e9..6d07853 100644 --- a/test/SimulatorAPI.test.ts +++ b/test/SimulatorAPI.test.ts @@ -1,12 +1,7 @@ import { expect, it, vi } from "vitest" -import type { - APITransactions} from "../src"; -import { - APIRequestType, - ClientRequestType, - SimulatorAPI, -} from "../src" +import type { APITransactions } from "../src" +import { APIRequestType, ClientRequestType, SimulatorAPI } from "../src" import { createRequestMessage } from "../src/channel" it("instantiates correctly", () => { @@ -101,6 +96,35 @@ const callsPostFormattedRequestCorrectly = < }, ] +it("passes allowedOrigin through to receiver options", () => { + const simulatorAPI = new SimulatorAPI( + { + [ClientRequestType.SetSliceZone]: (_req, res) => { + return res.success() + }, + [ClientRequestType.ScrollToSlice]: (_req, res) => { + return res.success() + }, + }, + { allowedOrigin: "https://example.com" }, + ) + + expect(simulatorAPI.options.allowedOrigin).toBe("https://example.com") +}) + +it("defaults allowedOrigin to null when not provided", () => { + const simulatorAPI = new SimulatorAPI({ + [ClientRequestType.SetSliceZone]: (_req, res) => { + return res.success() + }, + [ClientRequestType.ScrollToSlice]: (_req, res) => { + return res.success() + }, + }) + + expect(simulatorAPI.options.allowedOrigin).toBeNull() +}) + it(...callsPostFormattedRequestCorrectly(APIRequestType.SetActiveSlice, null)) it( ...callsPostFormattedRequestCorrectly(APIRequestType.SetSliceZoneSize, { diff --git a/test/channel-ChannelReceiver-allowedOrigin.test.ts b/test/channel-ChannelReceiver-allowedOrigin.test.ts new file mode 100644 index 0000000..5243a4a --- /dev/null +++ b/test/channel-ChannelReceiver-allowedOrigin.test.ts @@ -0,0 +1,201 @@ +import { expect, it, vi } from "vitest" + +import type { UnknownRequestMessage } from "../src/channel" +import { + ChannelReceiver, + InternalEmitterRequestType, + createRequestMessage, + createSuccessResponseMessage, +} from "../src/channel" + +class StandaloneChannelReceiver extends ChannelReceiver {} + +const dummyData = { foo: "bar" } + +// --- Inbound origin validation --- + +it("silently drops messages from non-matching origins when allowedOrigin is set", () => { + const channelReceiver = new StandaloneChannelReceiver( + {}, + { allowedOrigin: "https://example.com" }, + ) + // @ts-expect-error - taking a shortcut by accessing protected property + const postResponseStub = vi.spyOn(channelReceiver, "postResponse") + + const channel = new MessageChannel() + const request = createRequestMessage( + InternalEmitterRequestType.Connect, + undefined, + ) + + // @ts-expect-error - taking a shortcut by accessing private property + channelReceiver._onPublicMessage({ + data: request, + origin: "https://evil.com", + ports: [channel.port1], + }) + + expect(postResponseStub).not.toHaveBeenCalled() +}) + +it("accepts messages from matching origins when allowedOrigin is set", () => { + const channelReceiver = new StandaloneChannelReceiver( + {}, + { allowedOrigin: "https://example.com" }, + ) + // @ts-expect-error - taking a shortcut by accessing protected property + const postResponseStub = vi.spyOn(channelReceiver, "postResponse") + + const channel = new MessageChannel() + const request = createRequestMessage( + InternalEmitterRequestType.Connect, + undefined, + ) + const response = createSuccessResponseMessage(request.requestID, undefined) + + // @ts-expect-error - taking a shortcut by accessing private property + channelReceiver._onPublicMessage({ + data: request, + origin: "https://example.com", + ports: [channel.port1], + }) + + expect(postResponseStub).toHaveBeenCalledOnce() + expect(postResponseStub).toHaveBeenCalledWith(response) +}) + +it("accepts messages from any origin when allowedOrigin is null (default)", () => { + const channelReceiver = new StandaloneChannelReceiver({}, {}) + // @ts-expect-error - taking a shortcut by accessing protected property + const postResponseStub = vi.spyOn(channelReceiver, "postResponse") + + const channel = new MessageChannel() + const request = createRequestMessage( + InternalEmitterRequestType.Connect, + undefined, + ) + const response = createSuccessResponseMessage(request.requestID, undefined) + + // @ts-expect-error - taking a shortcut by accessing private property + channelReceiver._onPublicMessage({ + data: request, + origin: "https://any-origin.com", + ports: [channel.port1], + }) + + expect(postResponseStub).toHaveBeenCalledOnce() + expect(postResponseStub).toHaveBeenCalledWith(response) +}) + +// --- Outbound targetOrigin in ready() --- + +it("uses allowedOrigin as targetOrigin in ready() postMessage", async () => { + const channelReceiver = new StandaloneChannelReceiver( + {}, + { allowedOrigin: "https://example.com" }, + ) + + // Mock `window.parent.postMessage` + const windowParentBck = window.parent + // @ts-expect-error - deleting for test purpose + delete window.parent + const postMessageMock = vi.fn( + (request: UnknownRequestMessage, targetOrigin: string) => { + const response = createSuccessResponseMessage( + request.requestID, + undefined, + ) + // @ts-expect-error - taking a shortcut by accessing private property + channelReceiver._onPublicMessage({ + data: response, + origin: "https://example.com", + }) + }, + ) + window.parent = { + postMessage: postMessageMock as Window["postMessage"], + } as Window["parent"] + + await channelReceiver.ready() + + expect(postMessageMock).toHaveBeenCalledOnce() + expect(postMessageMock.mock.calls[0][1]).toBe("https://example.com") + + window.parent = windowParentBck +}) + +it("uses '*' as targetOrigin in ready() when allowedOrigin is null", async () => { + const channelReceiver = new StandaloneChannelReceiver({}, {}) + + // Mock `window.parent.postMessage` + const windowParentBck = window.parent + // @ts-expect-error - deleting for test purpose + delete window.parent + const postMessageMock = vi.fn( + (request: UnknownRequestMessage, targetOrigin: string) => { + const response = createSuccessResponseMessage( + request.requestID, + undefined, + ) + // @ts-expect-error - taking a shortcut by accessing private property + channelReceiver._onPublicMessage({ data: response }) + }, + ) + window.parent = { + postMessage: postMessageMock as Window["postMessage"], + } as Window["parent"] + + await channelReceiver.ready() + + expect(postMessageMock).toHaveBeenCalledOnce() + expect(postMessageMock.mock.calls[0][1]).toBe("*") + + window.parent = windowParentBck +}) + +// --- allowedOrigin preserved during Connect options merge --- + +it("preserves allowedOrigin when Connect request sends conflicting options", () => { + const channelReceiver = new StandaloneChannelReceiver( + {}, + { allowedOrigin: "https://example.com" }, + ) + + const channel = new MessageChannel() + + // Connect request data tries to overwrite allowedOrigin + const request = createRequestMessage(InternalEmitterRequestType.Connect, { + allowedOrigin: "https://evil.com", + someOtherOption: true, + }) + + // @ts-expect-error - taking a shortcut by accessing private property + channelReceiver._onPublicMessage({ + data: request, + origin: "https://example.com", + ports: [channel.port1], + }) + + expect(channelReceiver.options.allowedOrigin).toBe("https://example.com") + // Verify that non-protected options ARE merged + expect(channelReceiver.options.someOtherOption).toBe(true) +}) + +it("preserves null allowedOrigin during Connect options merge", () => { + const channelReceiver = new StandaloneChannelReceiver({}, {}) + + const channel = new MessageChannel() + + // Connect request data tries to set allowedOrigin + const request = createRequestMessage(InternalEmitterRequestType.Connect, { + allowedOrigin: "https://evil.com", + }) + + // @ts-expect-error - taking a shortcut by accessing private property + channelReceiver._onPublicMessage({ + data: request, + ports: [channel.port1], + }) + + expect(channelReceiver.options.allowedOrigin).toBeNull() +}) From 580095c7e7cec4d9ca63b49dee08fc39bbc723c3 Mon Sep 17 00:00:00 2001 From: DriesOlbrechts Date: Fri, 3 Apr 2026 10:13:09 +0200 Subject: [PATCH 3/5] fix: use consistent null checking --- src/channel/ChannelReceiver.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/channel/ChannelReceiver.ts b/src/channel/ChannelReceiver.ts index 3be8f40..090d415 100644 --- a/src/channel/ChannelReceiver.ts +++ b/src/channel/ChannelReceiver.ts @@ -85,7 +85,10 @@ export abstract class ChannelReceiver< >( request, (request) => { - window.parent.postMessage(request, this.options.allowedOrigin ?? "*") + window.parent.postMessage( + request, + this.options.allowedOrigin ? this.options.allowedOrigin : "*", + ) }, { timeout: this.options.readyTimeout, From d8755f3c65e681abe69d1f8f039be0210d7a9f00 Mon Sep 17 00:00:00 2001 From: DriesOlbrechts Date: Fri, 3 Apr 2026 10:57:21 +0200 Subject: [PATCH 4/5] fix: add allowedOrigin to omits --- src/channel/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/channel/types.ts b/src/channel/types.ts index d014e52..0d0959d 100644 --- a/src/channel/types.ts +++ b/src/channel/types.ts @@ -119,7 +119,10 @@ export type InternalEmitterTransactions< RequestMessage< InternalEmitterRequestType.Connect, | Partial< - Omit + Omit< + TReceiverOptions, + "debug" | "requestIDPrefix" | "readyTimeout" | "allowedOrigin" + > > | undefined > From d19cece07ee4c14c859af24249ce1d58ec99c784 Mon Sep 17 00:00:00 2001 From: DriesOlbrechts Date: Fri, 3 Apr 2026 11:20:38 +0200 Subject: [PATCH 5/5] fix: remove unused variable --- test/channel-ChannelReceiver-allowedOrigin.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/channel-ChannelReceiver-allowedOrigin.test.ts b/test/channel-ChannelReceiver-allowedOrigin.test.ts index 5243a4a..8fc1d23 100644 --- a/test/channel-ChannelReceiver-allowedOrigin.test.ts +++ b/test/channel-ChannelReceiver-allowedOrigin.test.ts @@ -10,8 +10,6 @@ import { class StandaloneChannelReceiver extends ChannelReceiver {} -const dummyData = { foo: "bar" } - // --- Inbound origin validation --- it("silently drops messages from non-matching origins when allowedOrigin is set", () => {