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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePrevious } from '@cowprotocol/common-hooks'
import { deepEqual } from '@cowprotocol/common-utils'
import { getParentOrigin } from '@cowprotocol/iframe-transport'
import {
UpdateAppDataPayload,
UpdateParamsPayload,
widgetIframeTransport,
WidgetMethodsEmit,
Expand All @@ -26,6 +27,7 @@ import {
cacheWidgetMessage,
clearCachedWidgetMessage,
getCachedWidgetMessageMethods,
registerCachedMessageHandler,
replayCachedWidgetMessage,
} from '../utils/widgetMessagesCache.utils'
;(function initInjectedWidget() {
Expand Down Expand Up @@ -88,54 +90,60 @@ export function InjectedWidgetUpdater(): ReactNode {
return
}

const updateParamsHandler = (data: UpdateParamsPayload): void => {
if (
// If the data is the same as the previous data
prevData.current &&
deepEqual(prevData.current, data) &&
// And the pathname is the same as the current widget pathname, do nothing
// This is needed since the app updates the pathname independently of the widget params
window.location.pathname === data.urlParams.pathname
) {
return
}

// Update params
prevData.current = data

const appParams = data.appParams
const hooksEnabled = new URLSearchParams(data.urlParams.search).get('hooksEnabled') === 'true'

const errors = validateWidgetParams(appParams)
setHooksEnabled(hooksEnabled)

updateParams({
params: appParams,
errors,
})

// Navigate to the new path
navigate(data.urlParams, { replace: true })
}

// Start listening for messages inside of React
const updateParamsListener = widgetIframeTransport.listenToMessageFromWindow(
window,
window.parent,
WidgetMethodsListen.UPDATE_PARAMS,
(data) => {
if (
// If the data is the same as the previous data
prevData.current &&
deepEqual(prevData.current, data) &&
// And the pathname is the same as the current widget pathname, do nothing
// This is needed since the app updates the pathname independently of the widget params
window.location.pathname === data.urlParams.pathname
) {
return
}

// Update params
prevData.current = data

const appParams = data.appParams
const hooksEnabled = new URLSearchParams(data.urlParams.search).get('hooksEnabled') === 'true'

const errors = validateWidgetParams(appParams)
setHooksEnabled(hooksEnabled)

updateParams({
params: appParams,
errors,
})

// Navigate to the new path
navigate(data.urlParams, { replace: true })
},
updateParamsHandler,
parentOrigin,
)
registerCachedMessageHandler(WidgetMethodsListen.UPDATE_PARAMS, updateParamsHandler)

const updateAppDataHandler = (data: UpdateAppDataPayload): void => {
if (data.metaData) {
updateMetaData(data.metaData)
}
}

const updateAppDataListener = widgetIframeTransport.listenToMessageFromWindow(
window,
window.parent,
WidgetMethodsListen.UPDATE_APP_DATA,
(data) => {
if (data.metaData) {
updateMetaData(data.metaData)
}
},
updateAppDataHandler,
parentOrigin,
)
registerCachedMessageHandler(WidgetMethodsListen.UPDATE_APP_DATA, updateAppDataHandler)

// Process all cached messages
getCachedWidgetMessageMethods().forEach((method) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import { cacheWidgetMessage, clearCachedWidgetMessages, replayCachedWidgetMessage } from './widgetMessagesCache.utils'
import { getParentOrigin } from '@cowprotocol/iframe-transport'
import { WidgetMethodsListen } from '@cowprotocol/widget-lib'

import {
cacheWidgetMessage,
clearCachedWidgetMessages,
registerCachedMessageHandler,
replayCachedWidgetMessage,
} from './widgetMessagesCache.utils'

jest.mock('@cowprotocol/iframe-transport', () => ({
...jest.requireActual('@cowprotocol/iframe-transport'),
getParentOrigin: jest.fn(),
}))

const TRUSTED_ORIGIN = 'https://widget-configurator.example'

describe('widgetMessagesCache utils', () => {
beforeEach(() => {
clearCachedWidgetMessages()
;(getParentOrigin as jest.Mock).mockReturnValue(TRUSTED_ORIGIN)
})

afterEach(() => {
jest.restoreAllMocks()
})

it('replays cached widget messages with their original origin', () => {
const messageListener = jest.fn()
it('calls the registered handler directly with cached message data', () => {
const handler = jest.fn()
const eventData = {
key: 'cowSwapWidget',
method: 'UPDATE_PARAMS',
Expand All @@ -21,40 +37,70 @@ describe('widgetMessagesCache utils', () => {

cacheWidgetMessage(
new MessageEvent('message', {
origin: 'https://widget-configurator.example',
origin: TRUSTED_ORIGIN,
source: window, // window.parent === window in jsdom
data: eventData,
}),
)

window.addEventListener('message', messageListener)
registerCachedMessageHandler(WidgetMethodsListen.UPDATE_PARAMS, handler)
replayCachedWidgetMessage('UPDATE_PARAMS')
window.removeEventListener('message', messageListener)

expect(messageListener).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledWith(eventData)
})

it('does nothing if no handler is registered for the method', () => {
const eventData = {
key: 'cowSwapWidget',
method: 'UPDATE_PARAMS',
urlParams: { pathname: '/1/widget/swap', search: '' },
appParams: {},
hasProvider: false,
}

const replayedEvent = messageListener.mock.calls[0][0] as MessageEvent
cacheWidgetMessage(
new MessageEvent('message', {
origin: TRUSTED_ORIGIN,
source: window,
data: eventData,
}),
)

expect(() => replayCachedWidgetMessage('UPDATE_PARAMS')).not.toThrow()
})

it('ignores messages with untrusted origin', () => {
const handler = jest.fn()

cacheWidgetMessage(
new MessageEvent('message', {
origin: 'https://evil.example',
source: window,
data: { key: 'cowSwapWidget', method: 'UPDATE_PARAMS' },
}),
)

registerCachedMessageHandler(WidgetMethodsListen.UPDATE_PARAMS, handler)
replayCachedWidgetMessage('UPDATE_PARAMS')

expect(replayedEvent.origin).toBe('https://widget-configurator.example')
expect(replayedEvent.data).toEqual(eventData)
expect(handler).not.toHaveBeenCalled()
})

it('ignores non-widget messages', () => {
const messageListener = jest.fn()
const handler = jest.fn()

cacheWidgetMessage(
new MessageEvent('message', {
origin: 'https://widget-configurator.example',
data: {
key: 'different-key',
method: 'UPDATE_PARAMS',
},
origin: TRUSTED_ORIGIN,
source: window,
data: { key: 'different-key', method: 'UPDATE_PARAMS' },
}),
)

window.addEventListener('message', messageListener)
registerCachedMessageHandler(WidgetMethodsListen.UPDATE_PARAMS, handler)
replayCachedWidgetMessage('UPDATE_PARAMS')
window.removeEventListener('message', messageListener)

expect(messageListener).not.toHaveBeenCalled()
expect(handler).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -1,41 +1,44 @@
import { widgetIframeTransport } from '@cowprotocol/widget-lib'
import { getParentOrigin, isLocalEnvOrigin } from '@cowprotocol/iframe-transport'
import { widgetIframeTransport, WidgetMethodsListen } from '@cowprotocol/widget-lib'

interface CachedMessageEnvelope {
data: unknown
origin: string
source: MessageEventSource | null
}

const messagesCache: Record<string, CachedMessageEnvelope> = {}
const handlers: Record<string, (data: never) => void> = {}

export function registerCachedMessageHandler(method: WidgetMethodsListen, handler: (data: never) => void): void {
handlers[method] = handler
}

export function cacheWidgetMessage(event: MessageEvent): void {
const method = getEventMethod(event)
if (!method) return

const trustedOrigin = getParentOrigin()
if (!trustedOrigin) return

if (!method) {
return
if (event.origin !== trustedOrigin) return
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important! Since we bypass IframeTransport for the cached messages, we validate the event here as well.


if (event.source !== window.parent) {
const isLocalEnv = isLocalEnvOrigin(event.origin) || isLocalEnvOrigin(trustedOrigin)
if (!isLocalEnv) return
}

messagesCache[method] = {
data: event.data,
origin: event.origin,
source: event.source,
}
}

export function replayCachedWidgetMessage(method: string): void {
const cachedMessage = messagesCache[method]
if (!cachedMessage) return

if (!cachedMessage) {
return
}
const handler = handlers[method]
if (!handler) return

window.dispatchEvent(
new MessageEvent('message', {
origin: cachedMessage.origin,
data: cachedMessage.data,
source: cachedMessage.source,
}),
)
handler(cachedMessage.data as never)
}

export function getCachedWidgetMessageMethods(): string[] {
Expand All @@ -44,6 +47,7 @@ export function getCachedWidgetMessageMethods(): string[] {

export function clearCachedWidgetMessage(method: string): void {
delete messagesCache[method]
delete handlers[method]
}

export function clearCachedWidgetMessages(): void {
Expand Down
2 changes: 1 addition & 1 deletion libs/iframe-transport/src/IframeTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function logWidget(...args: unknown[]): void {
console.debug('%c [COW][Widget]', 'font-weight: bold; color: #ff0000', ...args)
}

function isLocalEnvOrigin(origin: string): boolean {
export function isLocalEnvOrigin(origin: string): boolean {
try {
const { hostname } = new URL(origin)

Expand Down
2 changes: 1 addition & 1 deletion libs/iframe-transport/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { IframeRpcProviderBridge } from './iframeRpcProvider/IframeRpcProviderBridge'
export { WidgetEthereumProvider } from './iframeRpcProvider/WidgetEthereumProvider'
export { IframeTransport } from './IframeTransport'
export { IframeTransport, isLocalEnvOrigin } from './IframeTransport'
export { getParentOrigin } from './getParentOrigin'
export * from './iframeRpcProvider/iframeRpcProviderEvents'
export * from './types'
Loading