From ee6aa34c8ee973842f1618bf0e1e741423502cd6 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Sat, 28 Mar 2026 07:49:39 +1100 Subject: [PATCH] fix: support postMessage options overload and handle transferable clone failures MessageSandbox.postMessage previously did not support the modern postMessage(message, { targetOrigin, transfer }) overload, silently dropping such calls. This broke iframe communication for applications using the options-based API. Additionally, in Firefox the Hammerhead message envelope can cause DataCloneError ("MessagePort object could not be cloned") when transferable objects are present in the transfer list. The structured clone algorithm in Firefox fails to reconcile the wrapped message with the original transfer list. This commit: - Adds support for the postMessage(message, options) overload by detecting an object second argument and extracting targetOrigin and transfer from it. - Wraps the fastApply call in a try/catch so that when structured clone fails (e.g. Firefox with MessagePort transfers), the original unwrapped message is sent as a fallback. - Updates the receiving side (MessageEvent.data getter and _onWindowMessage) to pass through messages that do not carry the Hammerhead user-message envelope, since they may arrive from the fallback path. - Updates tests to cover the options overload and adjusts the existing "object as targetOrigin" test to reflect the new behavior. Made-with: Cursor --- src/client/sandbox/event/message.ts | 73 ++++++++++++++++--- .../fixtures/sandbox/event/message-test.js | 48 +++++++++--- 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/client/sandbox/event/message.ts b/src/client/sandbox/event/message.ts index 14b914d9c..2b875bb69 100644 --- a/src/client/sandbox/event/message.ts +++ b/src/client/sandbox/event/message.ts @@ -107,14 +107,22 @@ export default class MessageSandbox extends SandboxBase { private _onWindowMessage (e: MessageEvent, originListener): void { const data = MessageSandbox._getMessageData(e); - if (data.type !== MessageType.Service) { + if (data.type === MessageType.Service) + return null; + + if (data.type === MessageType.User) { const originUrl = destLocation.get(); if (data.targetUrl === '*' || destLocation.sameOriginCheck(originUrl, data.targetUrl)) return callEventListener(this.window, originListener, e); + + return null; } - return null; + // NOTE: unwrapped messages arrive when the Hammerhead envelope could not be structured-cloned + // (e.g. Firefox with MessagePort transfers). The browser already validated the target origin + // before delivering the MessageEvent, so it is safe to pass through to the listener. + return callEventListener(this.window, originListener, e); } private static _wrapMessage (type: MessageType, message, targetUrl?: string) { @@ -189,7 +197,10 @@ export default class MessageSandbox extends SandboxBase { const target = nativeMethods.eventTargetGetter.call(this); const data = nativeMethods.messageEventDataGetter.call(this); - if (data && data.type !== MessageType.Service && isWindow(target)) + // NOTE: only unwrap messages that carry the Hammerhead user-message envelope. + // Raw (unwrapped) messages — which may arrive when the envelope could not be + // structured-cloned alongside transferable objects — are returned as-is. + if (data && data.type === MessageType.User && isWindow(target)) return MessageSandbox._getOriginMessageData(data); return data; @@ -215,21 +226,59 @@ export default class MessageSandbox extends SandboxBase { postMessage (contentWindow: Window, args) { const targetUrl = args[1] || destLocation.getOriginHeader(); - // NOTE: We do NOT support the postMessage(message, options) overload. - // The second argument is expected to be `targetOrigin` (string). - // If an options object is provided instead, the call is considered invalid and will be aborted. + // NOTE: postMessage has two overloads: + // 1. postMessage(message, targetOrigin, transfer?) — legacy + // 2. postMessage(message, { targetOrigin, transfer? }) — modern options overload if (typeof targetUrl !== 'string') { - nativeMethods.consoleMeths.log(`testcafe-hammerhead: postMessage called with invalid targetOrigin; aborting call (type: ${typeof targetUrl})`); + if (targetUrl && typeof targetUrl === 'object') + return this._postMessageWithOptionsOverload(contentWindow, args, targetUrl); + return null; } - // NOTE: Here, we pass all messages as "no preference" ("*"). - // We do an origin check in "_onWindowMessage" to access the target origin. - args[1] = '*'; - args[0] = MessageSandbox._wrapMessage(MessageType.User, args[0], targetUrl); + return this._postMessageWrapped(contentWindow, args, targetUrl); + } + + private _postMessageWithOptionsOverload (contentWindow: Window, args, options) { + const resolvedTargetUrl = typeof options.targetOrigin === 'string' + ? options.targetOrigin + : destLocation.getOriginHeader(); + + const originalMessage = args[0]; + + args[0] = MessageSandbox._wrapMessage(MessageType.User, originalMessage, resolvedTargetUrl); + args[1] = nativeMethods.objectAssign({}, options, { targetOrigin: '*' }); + + try { + return fastApply(contentWindow, 'postMessage', args); + } + catch (err) { + args[0] = originalMessage; + args[1] = nativeMethods.objectAssign({}, options, { targetOrigin: '*' }); + + return fastApply(contentWindow, 'postMessage', args); + } + } + private _postMessageWrapped (contentWindow: Window, args, targetUrl: string) { + const originalMessage = args[0]; - return fastApply(contentWindow, 'postMessage', args); + args[1] = '*'; + args[0] = MessageSandbox._wrapMessage(MessageType.User, originalMessage, targetUrl); + + try { + return fastApply(contentWindow, 'postMessage', args); + } + catch (err) { + // NOTE: structured clone may fail when transferable objects (e.g. MessagePort) are + // present in the transfer list. This is observed in Firefox where the Hammerhead + // message envelope breaks the clone+transfer semantics. Fall back to sending the + // original message without wrapping — the receiving side handles unwrapped messages. + args[0] = originalMessage; + args[1] = '*'; + + return fastApply(contentWindow, 'postMessage', args); + } } sendServiceMsg (msg, targetWindow: Window, ports?: Transferable[]) { diff --git a/test/client/fixtures/sandbox/event/message-test.js b/test/client/fixtures/sandbox/event/message-test.js index 6d2e6cc2d..dcf582fbd 100644 --- a/test/client/fixtures/sandbox/event/message-test.js +++ b/test/client/fixtures/sandbox/event/message-test.js @@ -35,20 +35,46 @@ asyncTest('should pass "transfer" argument for "postMessage" (GH-1535)', functio callMethod(window, 'postMessage', ['test', '*', [channel.port1]]); }); -asyncTest('should not accept an object as "targetOrigin"', function () { - var called = false; - var handler = function () { - called = true; +asyncTest('should support postMessage(message, options) overload', function () { + var eventHandlerObject = { + handleEvent: function (e) { + strictEqual(e.data, 'options-overload-test'); + window.removeEventListener('message', eventHandlerObject); + start(); + }, }; - window.addEventListener('message', handler); - callMethod(window, 'postMessage', ['message', { test: 1 }]); + window.addEventListener('message', eventHandlerObject); + callMethod(window, 'postMessage', ['options-overload-test', { targetOrigin: '*' }]); +}); - window.setTimeout(function () { - ok(!called, 'message should not be delivered'); - window.removeEventListener('message', handler); - start(); - }, 100); +asyncTest('should support postMessage(message, options) overload with transfer', function () { + var channel = new MessageChannel(); + + var eventHandlerObject = { + handleEvent: function (e) { + strictEqual(e.data, 'options-transfer-test'); + strictEqual(e.ports.length, 1); + window.removeEventListener('message', eventHandlerObject); + start(); + }, + }; + + window.addEventListener('message', eventHandlerObject); + callMethod(window, 'postMessage', ['options-transfer-test', { targetOrigin: '*', transfer: [channel.port1] }]); +}); + +asyncTest('should deliver message when postMessage is called with an object as second argument', function () { + var handler = function (e) { + if (e.data === 'object-arg-test') { + ok(true, 'message should be delivered via options overload'); + window.removeEventListener('message', handler); + start(); + } + }; + + window.addEventListener('message', handler); + callMethod(window, 'postMessage', ['object-arg-test', { test: 1 }]); }); asyncTest('onmessage event', function () {