Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
41 changes: 28 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MessageTarget, Window>).postMessage(
message,
Expand All @@ -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')
}
Expand All @@ -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
Expand Down