diff --git a/README.md b/README.md index d6adb437..3da1c2ee 100644 --- a/README.md +++ b/README.md @@ -2656,7 +2656,7 @@ const SomeComponent = () => { ### Safari: Allowing JavaScript execution in tabs opened by in-app message link clicks To display an in-app message, Iterable's Web SDK uses an `iframe` on which the -`sandbox` attribute is set to `allow-same-origin allow-popups allow-top-navigation`. +`sandbox` attribute is set to `allow-same-origin allow-scripts allow-popups allow-top-navigation`. On Safari, this configuration blocks JavaScript execution in tabs that open because of link clicks in the `iframe`. diff --git a/src/inapp/inapp.ts b/src/inapp/inapp.ts index 1c954925..04377a90 100644 --- a/src/inapp/inapp.ts +++ b/src/inapp/inapp.ts @@ -37,7 +37,6 @@ import { getHostnameFromUrl, paintIFrame, paintOverlay, - setCloseButtonPosition, sortInAppMessages, trackMessagesDelivered, wrapWithIFrame @@ -273,20 +272,7 @@ export function getInAppMessages( ); } - const ua = navigator.userAgent; - const isSafari = - !!ua.match(/safari/i) && !ua.match(/chrome|chromium|crios/i); - - /** - * We allow users to dismiss messages by clicking outside of the - * message not only when isRequiredToDismissMessage is not true - * but also when browser is detected to be Safari, regardless of - * whether isRequiredToDismissMessage is true. Safari blocks - * all bound event handlers and so we cannot execute Javascript - * to listen for click events. As such, we should not prevent users - * from being able to dismiss the message by clicking outside of it. - */ - if (!payload.closeButton?.isRequiredToDismissMessage || isSafari) { + if (!payload.closeButton?.isRequiredToDismissMessage) { overlay.addEventListener('click', () => { dismissMessage(activeIframe); document.getElementById(CLOSE_X_BUTTON_ID)?.remove(); @@ -315,7 +301,7 @@ export function getInAppMessages( if (activeIframeDocument) { const absoluteDismissButton = generateAbsoluteDismissButton({ id: ABSOLUTE_DISMISS_BUTTON_ID, - document: isSafari ? document : activeIframeDocument + document: activeIframeDocument }); const triggerClose = () => { @@ -344,10 +330,6 @@ export function getInAppMessages( * Here we paint an optional close button if the user provided configuration * values. This button is just a quality-of-life feature so that the customer will * have an easy way to close the modal outside of the other methods. - * - * Do not show close button if browser is detected to be Safari because the close - * button will not be able to dismiss the message (Safari blocks JS from running - * on bound event handlers) */ if (payload.closeButton) { const { position, color, size, iconPath, topOffset, sideOffset } = @@ -355,7 +337,7 @@ export function getInAppMessages( const closeXButton = generateCloseButton( CLOSE_X_BUTTON_ID, - isSafari ? document : activeIframeDocument, + activeIframeDocument, position, color, size, @@ -365,32 +347,7 @@ export function getInAppMessages( ); closeXButton.addEventListener('click', triggerClose); - if (isSafari) { - const setPosition = () => - setCloseButtonPosition( - activeIframe, - closeXButton, - position, - sideOffset, - topOffset - ); - - /** - * Due to DOM manipulations made in other timeouts when painting the iframe, - * getBoundingClientRect() will not work unless it waits for those manipulations - * to complete. Setting a trivial timeout here to account for this. - */ - setTimeout(() => { - setPosition(); - document.body.appendChild(closeXButton); - }, 100); - - const repositionCloseButton = () => - messagePosition !== 'Full' ? setPosition() : null; - global.addEventListener('resize', repositionCloseButton); - } else { - activeIframeDocument?.body.appendChild(closeXButton); - } + activeIframeDocument?.body.appendChild(closeXButton); } } @@ -469,128 +426,93 @@ export function getInAppMessages( addButtonAttrsToAnchorTag(link, 'close modal'); } - /** - Safari blocks all bound event handlers (including our click event handlers) - in iframes, so links will not work in Safari unless we circumvent the - restriction by appending target to each link tag. - - @note Because click event handlers cannot be attached to iframe links in - Safari, we cannot track in-app click metrics for Safari in Iterable analytics. - */ - if (isSafari) { - if (!isIterableKeywordLink) { - manageHandleLinks( - () => link.setAttribute('target', '_top'), - () => { - link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener noreferrer'); - } - ); - const targetAttr = link.getAttribute('target'); - if ( - !handleLinks && - (targetAttr === null || targetAttr === '_self') - ) { - link.setAttribute('target', '_top'); - } - } - } else { - link.addEventListener('click', (event) => { - /* + link.addEventListener('click', (event) => { + /* remove default linking behavior because we're in an iframe so we need to link the user programatically */ - event.preventDefault(); - - if (clickedUrl) { - const isOpeningLinkInSameTab = - (!handleLinks && !openInNewTab) || - handleLinks === HandleLinks.OpenAllSameTab || - (isInternalLink && - handleLinks === HandleLinks.ExternalNewTab); - - trackInAppClick( - { - clickedUrl, - messageId: activeMessage?.messageId, - deviceInfo: { - appPackageName: dupedPayload.packageName - } - }, - /* + event.preventDefault(); + + if (clickedUrl) { + const isOpeningLinkInSameTab = + (!handleLinks && !openInNewTab) || + handleLinks === HandleLinks.OpenAllSameTab || + (isInternalLink && + handleLinks === HandleLinks.ExternalNewTab); + + trackInAppClick( + { + clickedUrl, + messageId: activeMessage?.messageId, + deviceInfo: { + appPackageName: dupedPayload.packageName + } + }, + /* only call with the fetch API if we're linking in the same tab and it's not a reserved keyword link. */ - isOpeningLinkInSameTab && !isIterableKeywordLink - /* swallow the network error */ - ).catch((e: any) => e); - - if (isDismissNode || isActionLink) { - dismissMessage(activeIframe, clickedUrl); - document.removeEventListener( + isOpeningLinkInSameTab && !isIterableKeywordLink + /* swallow the network error */ + ).catch((e: any) => e); + + if (isDismissNode || isActionLink) { + dismissMessage(activeIframe, clickedUrl); + document.removeEventListener( + 'keydown', + handleDocumentEscPress + ); + if (activeIframeDocument) { + activeIframeDocument.removeEventListener( 'keydown', - handleDocumentEscPress + handleIFrameEscPress ); - if (activeIframeDocument) { - activeIframeDocument.removeEventListener( - 'keydown', - handleIFrameEscPress - ); - } - global.removeEventListener('resize', throttledResize); } + global.removeEventListener('resize', throttledResize); + } - if (isActionLink) { - // eslint-disable-next-line prefer-regex-literals - const filteredMatch = (new RegExp( - /^.*action:\/\/(.*)$/, - 'gmi' - )?.exec(clickedUrl) || [])?.[1]; - /* + if (isActionLink) { + // eslint-disable-next-line prefer-regex-literals + const filteredMatch = (new RegExp( + /^.*action:\/\/(.*)$/, + 'gmi' + )?.exec(clickedUrl) || [])?.[1]; + /* just post the message to the window when clicking action:// links and early return */ - return global.postMessage( - { type: 'iterable-action-link', data: filteredMatch }, - '*' - ); - } + return global.postMessage( + { type: 'iterable-action-link', data: filteredMatch }, + '*' + ); + } - /* + /* finally (since we're in an iframe), programatically click the link and send the user to where they need to go, only if it's not one of the reserved iterable keyword links */ - if (!isIterableKeywordLink) { - manageHandleLinks( - () => global.location.assign(clickedUrl), - () => { - global.open( - clickedUrl, - '_blank', - 'noopener,noreferrer' - ); - } - ); - if (!handleLinks) { - if (openInNewTab) { - /** + if (!isIterableKeywordLink) { + manageHandleLinks( + () => global.location.assign(clickedUrl), + () => { + global.open(clickedUrl, '_blank', 'noopener,noreferrer'); + } + ); + if (!handleLinks) { + if (openInNewTab) { + /** Using target="_blank" without rel="noreferrer" and rel="noopener" makes the website vulnerable to window.opener API exploitation attacks @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#security_and_privacy */ - global.open( - clickedUrl, - '_blank', - 'noopener,noreferrer' - ); - } else global.location.assign(clickedUrl); - } + global.open(clickedUrl, '_blank', 'noopener,noreferrer'); + } else global.location.assign(clickedUrl); } } - }); - } + } + }); }); return activeIframe; diff --git a/src/inapp/tests/inapp.test.ts b/src/inapp/tests/inapp.test.ts index 28433e36..23eab462 100644 --- a/src/inapp/tests/inapp.test.ts +++ b/src/inapp/tests/inapp.test.ts @@ -139,8 +139,7 @@ describe('getInAppMessages', () => { beforeAll(async () => { jest.useFakeTimers(); - // Mock navigator.userAgent to simulate a non-Safari browser - // This ensures click tracking works in tests (Safari blocks iframe event handlers) + // Mock navigator.userAgent to simulate a browser environment for tests Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', diff --git a/src/inapp/utils.ts b/src/inapp/utils.ts index 645a9a2d..b40f2194 100644 --- a/src/inapp/utils.ts +++ b/src/inapp/utils.ts @@ -286,11 +286,12 @@ const mediaQueryXl = global?.matchMedia?.('(min-width: 1301px)'); const generateSecuredIFrame = () => { const iframe = document.createElement('iframe'); iframe.setAttribute('id', 'iterable-iframe'); - // allow-popups and allow-top-navigation is to enable links for Safari since the iframe will block - // event handlers on elements in it preventing our custom link handling + // allow-scripts is needed so that click event handlers work inside the iframe + // (especially in Safari, which blocks all JS in sandboxed iframes without it). + // allow-popups and allow-top-navigation enable links to navigate or open new tabs. iframe.setAttribute( 'sandbox', - `allow-same-origin allow-popups allow-top-navigation ${ + `allow-same-origin allow-scripts allow-popups allow-top-navigation ${ config.getConfig('dangerouslyAllowJsPopups') ? 'allow-popups-to-escape-sandbox' : ''