Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
210 changes: 66 additions & 144 deletions src/inapp/inapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
getHostnameFromUrl,
paintIFrame,
paintOverlay,
setCloseButtonPosition,
sortInAppMessages,
trackMessagesDelivered,
wrapWithIFrame
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -344,18 +330,14 @@ 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 } =
payload.closeButton;

const closeXButton = generateCloseButton(
CLOSE_X_BUTTON_ID,
isSafari ? document : activeIframeDocument,
activeIframeDocument,
position,
color,
size,
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions src/inapp/tests/inapp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 4 additions & 3 deletions src/inapp/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
: ''
Expand Down
Loading