diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 23c8b78..6decf34 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -454,6 +454,179 @@ describe('createChannel integration tests', () => { }) }) +describe('origin validation', () => { + function createMockWindowTarget() { + const target: any = { self: null as any } + target.self = target + target.postMessage = vi.fn() + return target + } + + function mockPort() { + return { + postMessage: vi.fn(), + start: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + } + + it('should reject connections from an origin other than the declared one', async () => { + const mockTarget = createMockWindowTarget() + const channel = createChannel( + mockTarget, + 'reject-test', + 'https://trusted.com' + ) + + const port = mockPort() + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'bidc-connect', + channelId: 'bidc_reject-test', + timestamp: Date.now() + 1000, + }, + origin: 'https://evil.com', + ports: [port] as any, + }) + ) + + await new Promise((r) => setTimeout(r, 50)) + expect(port.postMessage).not.toHaveBeenCalled() + + channel.cleanup() + }) + + it('should accept connections from the declared origin', async () => { + const mockTarget = createMockWindowTarget() + const channel = createChannel( + mockTarget, + 'accept-test', + 'https://trusted.com' + ) + + const port = mockPort() + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'bidc-connect', + channelId: 'bidc_accept-test', + timestamp: Date.now() + 1000, + }, + origin: 'https://trusted.com', + ports: [port] as any, + }) + ) + + await new Promise((r) => setTimeout(r, 50)) + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'bidc-confirm' }) + ) + + channel.cleanup() + }) + + it('should accept any origin when none is declared (back-compat)', async () => { + const mockTarget = createMockWindowTarget() + const channel = createChannel(mockTarget, 'no-origin-test') + + const port = mockPort() + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'bidc-connect', + channelId: 'bidc_no-origin-test', + timestamp: Date.now() + 1000, + }, + origin: 'https://any-origin.com', + ports: [port] as any, + }) + ) + + await new Promise((r) => setTimeout(r, 50)) + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'bidc-confirm' }) + ) + + channel.cleanup() + }) + + it('should use declared origin as targetOrigin in postMessage', () => { + const mockTarget = createMockWindowTarget() + const channel = createChannel( + mockTarget, + 'target-origin-test', + 'https://trusted.com' + ) + + expect(mockTarget.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'bidc-connect' }), + 'https://trusted.com', + expect.any(Array) + ) + + channel.cleanup() + }) + + it('should use * as targetOrigin when no origin is declared', () => { + const mockTarget = createMockWindowTarget() + const channel = createChannel(mockTarget, 'wildcard-test') + + expect(mockTarget.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'bidc-connect' }), + '*', + expect.any(Array) + ) + + channel.cleanup() + }) + + it('should validate origin without a target (iframe context)', async () => { + // Simulate an iframe context where window.parent is the embedding page + const fakeParent = { postMessage: vi.fn() } + Object.defineProperty(window, 'parent', { + value: fakeParent, + configurable: true, + }) + + try { + const channel = createChannel('iframe-origin-test', 'https://parent.com') + + // Outbound connect message is restricted to the declared origin + expect(fakeParent.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'bidc-connect' }), + 'https://parent.com', + expect.any(Array) + ) + + // Inbound connect from a different origin is rejected + const port = mockPort() + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'bidc-connect', + channelId: 'bidc_iframe-origin-test', + timestamp: Date.now() + 1000, + }, + origin: 'https://evil.com', + ports: [port] as any, + }) + ) + + await new Promise((r) => setTimeout(r, 50)) + expect(port.postMessage).not.toHaveBeenCalled() + + channel.cleanup() + } finally { + Object.defineProperty(window, 'parent', { + value: window, + configurable: true, + }) + } + }) +}) + describe('edge cases', () => { it('should handle circular references with promises', async () => { const obj: any = { name: 'circular' } diff --git a/src/index.ts b/src/index.ts index df7db24..86830fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -335,22 +335,34 @@ type TChannel = { */ function createChannel(): TChannel function createChannel(targetOrChannelId: MessageTarget | string): TChannel +function createChannel(channelId: string, origin: string): TChannel function createChannel(maybeTarget: MessageTarget, channelId: string): TChannel +function createChannel( + maybeTarget: MessageTarget, + channelId: string, + origin: string +): TChannel function createChannel( targetOrChannelId?: MessageTarget | string, - channelId?: string + channelIdOrOrigin?: string, + maybeOrigin?: string ) { let maybeTarget: MessageTarget | undefined = undefined - if ( - typeof channelId === 'undefined' && - typeof targetOrChannelId === 'string' - ) { - // The first argument is channelId - channelId = targetOrChannelId - maybeTarget = undefined - } else if (typeof targetOrChannelId === 'object') { - // The first argument is a target + let channelId: string | undefined + // Expected origin of the other endpoint. When set, incoming connections + // from any other origin are rejected. Must be declared by the caller since + // it can't be derived from a cross-origin target. + let expectedOrigin: string | undefined + + if (typeof targetOrChannelId === 'object') { + // (target, channelId?, origin?) maybeTarget = targetOrChannelId + channelId = channelIdOrOrigin + expectedOrigin = maybeOrigin + } else { + // (channelId?, origin?) — no target, uses window.parent / self + channelId = targetOrChannelId + expectedOrigin = channelIdOrOrigin } // Namespaced channelId to avoid conflicts with other libraries / multiple @@ -375,8 +387,8 @@ function createChannel( function sendMessageWithTransfer(message: any, transfer: MessagePort) { if (maybeTarget) { if ('self' in maybeTarget && maybeTarget.self === maybeTarget) { - // It's an iframe contentWindow - maybeTarget.postMessage(message, '*', [transfer]) + // Restrict delivery to the expected origin when declared + maybeTarget.postMessage(message, expectedOrigin || '*', [transfer]) } else { ;(maybeTarget as Exclude).postMessage( message, @@ -395,7 +407,7 @@ function createChannel( window.parent !== window ) { // Inside an iframe, we can use window.parent - window.parent.postMessage(message, '*', [transfer]) + window.parent.postMessage(message, expectedOrigin || '*', [transfer]) } else { throw new Error('No target provided and no global context available') } @@ -419,6 +431,9 @@ function createChannel( const port = event.ports[0] as MessagePort | undefined if (!port) return + // Reject connections from any origin other than the expected one + if (expectedOrigin && event.origin !== expectedOrigin) return + const data = event.data as typeof connectMessage | typeof confirmMessage if (data?.channelId !== channelId) return if (data.type !== connectMessage.type) return