diff --git a/src/GleapAgentConversation.js b/src/GleapAgentConversation.js
index e637486c..3ba22a29 100644
--- a/src/GleapAgentConversation.js
+++ b/src/GleapAgentConversation.js
@@ -1,5 +1,6 @@
import { GleapSession } from './Gleap';
import { bootstrapGleapFrame } from './GleapHelper';
+import { frameTargetOrigin } from './GleapMessageSecurity';
import { GLEAP_DEFAULT_REGION, GLEAP_REGIONS } from './GleapRegions';
let agentConversationUrl = 'https://outboundmedia.gleap.io/agent-conversation';
@@ -141,7 +142,7 @@ export const registerGleapAgentComponents = () => {
this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-data', data: config }),
- '*'
+ frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}
@@ -149,7 +150,7 @@ export const registerGleapAgentComponents = () => {
if (!this._iframe?.contentWindow || !content?.trim()) return;
this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-send-message', data: { content } }),
- '*'
+ frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}
@@ -157,7 +158,7 @@ export const registerGleapAgentComponents = () => {
if (!this._iframe?.contentWindow) return;
this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-clear', data: {} }),
- '*'
+ frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}
}
diff --git a/src/GleapAiChatbarManager.js b/src/GleapAiChatbarManager.js
index cb0837dc..a33f6ceb 100644
--- a/src/GleapAiChatbarManager.js
+++ b/src/GleapAiChatbarManager.js
@@ -9,6 +9,7 @@ import {
import GleapAgentToolManager from './GleapAgentToolManager';
import GleapThemeManager from './GleapThemeManager';
import { applyGleapCSPNonce, bootstrapGleapFrame, runFunctionWhenDomIsReady } from './GleapHelper';
+import { frameTargetOrigin } from './GleapMessageSecurity';
import { GLEAP_DEFAULT_REGION, GLEAP_REGIONS } from './GleapRegions';
export default class GleapAiChatbarManager {
@@ -160,7 +161,10 @@ export default class GleapAiChatbarManager {
this.pendingMessages.push(message);
return;
}
- this.chatbarFrame.contentWindow.postMessage(JSON.stringify(message), '*');
+ this.chatbarFrame.contentWindow.postMessage(
+ JSON.stringify(message),
+ frameTargetOrigin(this.chatbarFrame, this.chatbarUrl)
+ );
} catch (e) {}
}
diff --git a/src/GleapBannerManager.js b/src/GleapBannerManager.js
index c3da0bad..f2dc456e 100644
--- a/src/GleapBannerManager.js
+++ b/src/GleapBannerManager.js
@@ -1,6 +1,7 @@
import Gleap, { GleapFrameManager } from './Gleap';
import { isOutboundActionBlocked } from './GleapCaptureSettings';
import { bootstrapGleapFrame } from './GleapHelper';
+import { originMatchesUrl } from './GleapMessageSecurity';
const clipsOverflow = (style) => {
if (!style) {
@@ -51,7 +52,7 @@ export default class GleapBannerManager {
? this.bannerContainer.querySelector('.gleap-b-frame')
: null;
const sourceMatches = bannerFrame && event.source === bannerFrame.contentWindow;
- const originMatches = this.bannerUrl?.includes(event.origin);
+ const originMatches = originMatchesUrl(event.origin, this.bannerUrl);
if (!sourceMatches && !originMatches) {
return;
}
diff --git a/src/GleapBannerManager.test.js b/src/GleapBannerManager.test.js
index 761e95cf..6ddf5313 100644
--- a/src/GleapBannerManager.test.js
+++ b/src/GleapBannerManager.test.js
@@ -3,10 +3,11 @@
*/
import GleapBannerManager, { bodyClipsInlineBanner } from './GleapBannerManager';
+const mockUrlHandler = jest.fn();
jest.mock('./Gleap', () => ({
__esModule: true,
default: {},
- GleapFrameManager: { getInstance: jest.fn(() => ({ urlHandler: jest.fn() })) },
+ GleapFrameManager: { getInstance: jest.fn(() => ({ urlHandler: mockUrlHandler })) },
}));
jest.mock('./GleapHelper', () => ({
@@ -90,3 +91,27 @@ describe('GleapBannerManager inline clip layout', () => {
expect(document.body.classList.contains('gleap-b-clip')).toBe(false);
});
});
+
+describe('GleapBannerManager message origin', () => {
+ const manager = GleapBannerManager.getInstance();
+ const post = (origin) =>
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: JSON.stringify({ type: 'BANNER', name: 'open-url', data: 'https://example.com' }),
+ origin,
+ })
+ );
+
+ beforeEach(() => mockUrlHandler.mockClear());
+
+ test('accepts the exact banner origin', () => {
+ post(new URL(manager.bannerUrl).origin);
+ expect(mockUrlHandler).toHaveBeenCalledWith('https://example.com', false);
+ });
+
+ test('ignores a look-alike origin that is a prefix of the banner URL', () => {
+ post('https://outboundmedia.gl');
+ post('https://outboundmedia.gleap');
+ expect(mockUrlHandler).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js
index 145735cb..e81ea4e4 100644
--- a/src/GleapFrameManager.js
+++ b/src/GleapFrameManager.js
@@ -22,6 +22,7 @@ import GleapAgentToolManager from './GleapAgentToolManager';
import { getCaptureCapabilities } from './GleapCaptureSettings';
import { forwardSurveyAnalyticsEvent } from './GleapSurveyAnalyticsForwarder';
import { bootstrapGleapFrame, loadFromGleapCache, runFunctionWhenDomIsReady, saveToGleapCache } from './GleapHelper';
+import { frameTargetOrigin, isSafeNavigationUrl, isScriptUrl } from './GleapMessageSecurity';
import { widgetExpandMinWidth, widgetLoaderMarkup, widgetMaxHeight } from './UI';
export default class GleapFrameManager {
@@ -61,16 +62,16 @@ export default class GleapFrameManager {
// ...and the survey's surface colour + light/dark (survey-theme), null = the widget's colours.
surveySurface = null;
surveyDark = null;
+ // Default link handler: http(s), mailto, tel and relative links only — never
+ // `javascript:` (it would run in the host page). New tabs get no opener.
urlHandler = function (url, newTab) {
- if (url && url.length > 0) {
- if (newTab) {
- const newWindow = window.open(url, '_blank');
- if (newWindow) {
- newWindow.focus();
- }
- } else {
- window.location.href = url;
- }
+ if (!isSafeNavigationUrl(url)) {
+ return;
+ }
+ if (newTab) {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ } else {
+ window.location.href = url;
}
};
@@ -120,7 +121,16 @@ export default class GleapFrameManager {
}
setUrlHandler(handler) {
- this.urlHandler = handler;
+ if (typeof handler !== 'function') {
+ return;
+ }
+ // A custom handler may open app deep links, but never script URLs.
+ this.urlHandler = (url, newTab) => {
+ if (isScriptUrl(url)) {
+ return;
+ }
+ return handler(url, newTab);
+ };
}
isSurvey() {
@@ -1023,7 +1033,10 @@ export default class GleapFrameManager {
try {
this.gleapFrame = document.querySelector('.gleap-frame');
if (this.comReady && this.gleapFrame && this.gleapFrame.contentWindow) {
- this.gleapFrame.contentWindow.postMessage(JSON.stringify(data), '*');
+ this.gleapFrame.contentWindow.postMessage(
+ JSON.stringify(data),
+ frameTargetOrigin(this.gleapFrame, this.frameUrl)
+ );
} else {
if (queue) {
this.queue.push(data);
@@ -1420,7 +1433,7 @@ export default class GleapFrameManager {
name: 'collect-ticket-data',
data: ticketData,
}),
- '*'
+ frameTargetOrigin(targetFrame, this.frameUrl)
);
}
} catch (e) {}
diff --git a/src/GleapFrameManager.test.js b/src/GleapFrameManager.test.js
index dc332727..c251d7b1 100644
--- a/src/GleapFrameManager.test.js
+++ b/src/GleapFrameManager.test.js
@@ -272,3 +272,48 @@ describe('GleapFrameManager expand/collapse window', () => {
expect(sentSizeUpdates().pop().data).toEqual({ expandable: false, expanded: false });
});
});
+
+describe('GleapFrameManager url handler', () => {
+ let open;
+ beforeEach(() => {
+ open = jest.spyOn(window, 'open').mockImplementation(() => null);
+ });
+ afterEach(() => open.mockRestore());
+
+ it('never opens script URLs', () => {
+ const fm = setup();
+ const before = window.location.href;
+ fm.urlHandler('javascript:alert(document.cookie)', false);
+ fm.urlHandler('java\tscript:alert(1)', false);
+ fm.urlHandler('javascript:alert(1)', true);
+ fm.urlHandler('data:text/html,', true);
+ expect(open).not.toHaveBeenCalled();
+ expect(window.location.href).toBe(before);
+ });
+
+ it('opens web links in a new tab without an opener', () => {
+ const fm = setup();
+ fm.urlHandler('https://example.com/docs', true);
+ expect(open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer');
+ });
+
+ it('runs a custom handler for deep links but never for script URLs', () => {
+ const fm = setup();
+ const custom = jest.fn();
+ fm.setUrlHandler(custom);
+ fm.urlHandler('myapp://settings', false);
+ fm.urlHandler('javascript:alert(1)', false);
+ fm.urlHandler('data:text/html,x', true);
+ expect(custom).toHaveBeenCalledTimes(1);
+ expect(custom).toHaveBeenCalledWith('myapp://settings', false);
+ });
+
+ it("relays the messenger's open-url through the safe handler", () => {
+ const fm = setup();
+ const before = window.location.href;
+ dispatch(fm, { name: 'open-url', data: 'javascript:alert(1)' });
+ dispatch(fm, { name: 'open-url', data: 'javascript:alert(1)', newTab: true });
+ expect(open).not.toHaveBeenCalled();
+ expect(window.location.href).toBe(before);
+ });
+});
diff --git a/src/GleapMessageSecurity.js b/src/GleapMessageSecurity.js
new file mode 100644
index 00000000..02feee18
--- /dev/null
+++ b/src/GleapMessageSecurity.js
@@ -0,0 +1,91 @@
+// Message / link hardening between the host page and the Gleap frames.
+
+// Links the SDK opens on the host page come from the widget, banners, modals,
+// the chatbar and Gleap.openURL. Navigating the host page to a `javascript:`
+// URL runs script in the customer's origin, so only plain web / mail / phone
+// links (and relative ones) are opened by the default handler.
+const SAFE_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'];
+
+// Never handed to any handler, not even a custom Gleap.setUrlHandler one.
+const SCRIPT_SCHEMES = ['javascript:', 'vbscript:', 'data:'];
+
+// Parsed the way the browser will navigate it (tabs/newlines and leading
+// spaces are stripped, so `java\tscript:` is caught too). Relative links
+// resolve against a dummy https base, i.e. count as https.
+const urlProtocol = (url) => {
+ if (typeof url !== 'string' || !url.trim()) {
+ return null;
+ }
+ try {
+ return new URL(url, 'https://relative.invalid/').protocol.toLowerCase();
+ } catch (e) {
+ return null;
+ }
+};
+
+/**
+ * Whether the default url handler may open this link.
+ * @param {*} url
+ * @returns {boolean}
+ */
+export const isSafeNavigationUrl = (url) => {
+ const protocol = urlProtocol(url);
+ return !!protocol && SAFE_SCHEMES.includes(protocol);
+};
+
+/**
+ * Whether a link would run script (or is not a usable link at all).
+ * @param {*} url
+ * @returns {boolean}
+ */
+export const isScriptUrl = (url) => {
+ const protocol = urlProtocol(url);
+ return !protocol || SCRIPT_SCHEMES.includes(protocol);
+};
+
+/**
+ * Exact origin match against a configured URL (e.g. the banner URL). A
+ * substring check also accepted look-alike origins such as
+ * `https://outboundmedia.gl` for `https://outboundmedia.gleap.io`.
+ * @param {string} origin event.origin
+ * @param {string} url the configured URL (may include a path)
+ * @returns {boolean}
+ */
+export const originMatchesUrl = (origin, url) => {
+ if (typeof origin !== 'string' || !origin || origin === 'null' || typeof url !== 'string') {
+ return false;
+ }
+ try {
+ return new URL(url).origin === origin;
+ } catch (e) {
+ return false;
+ }
+};
+
+/**
+ * The targetOrigin for posting into a frame we bootstrapped from `url`.
+ * Bootstrapped through about:blank the frame shares our origin, so messages are
+ * pinned to it; a frame that fell back to loading `url` directly is pinned to
+ * that URL's origin. '*' only when the frame's origin is opaque or unknown.
+ * @param {HTMLIFrameElement} iframe
+ * @param {string} url
+ * @returns {string}
+ */
+export const frameTargetOrigin = (iframe, url) => {
+ try {
+ // Readable only while the frame is same-origin (the about:blank bootstrap).
+ // window.origin is the document's (inherited) origin; location.origin of a
+ // fresh about:blank frame is "null" until the bootstrap writes it.
+ const frameWindow = iframe.contentWindow;
+ const origin = frameWindow.origin || frameWindow.location.origin;
+ return origin && origin !== 'null' ? origin : '*';
+ } catch (e) {
+ // Cross-origin: the src fallback.
+ }
+ try {
+ const origin = new URL(url).origin;
+ return origin && origin !== 'null' ? origin : '*';
+ } catch (e) {
+ return '*';
+ }
+};
diff --git a/src/GleapMessageSecurity.test.js b/src/GleapMessageSecurity.test.js
new file mode 100644
index 00000000..04d90e05
--- /dev/null
+++ b/src/GleapMessageSecurity.test.js
@@ -0,0 +1,93 @@
+/**
+ * @jest-environment jsdom
+ */
+import { frameTargetOrigin, isSafeNavigationUrl, isScriptUrl, originMatchesUrl } from './GleapMessageSecurity';
+
+describe('isSafeNavigationUrl (default url handler)', () => {
+ test.each(['https://example.com/a?b=c', 'http://example.com', 'mailto:help@example.com', 'tel:+4312345', '/pricing', 'docs/page'])(
+ 'allows %s',
+ (url) => {
+ expect(isSafeNavigationUrl(url)).toBe(true);
+ }
+ );
+
+ test.each([
+ 'javascript:alert(1)',
+ 'JAVASCRIPT:alert(1)',
+ ' javascript:alert(1)',
+ 'java\tscript:alert(1)',
+ 'java\nscript:alert(1)',
+ 'data:text/html,',
+ 'vbscript:msgbox(1)',
+ 'blob:https://example.com/x',
+ 'myapp://settings',
+ '',
+ undefined,
+ null,
+ {},
+ ])('rejects %p', (url) => {
+ expect(isSafeNavigationUrl(url)).toBe(false);
+ });
+});
+
+describe('isScriptUrl (guard in front of custom url handlers)', () => {
+ test('flags script schemes and non-links', () => {
+ expect(isScriptUrl('javascript:alert(1)')).toBe(true);
+ expect(isScriptUrl('java\tscript:alert(1)')).toBe(true);
+ expect(isScriptUrl('data:text/html,x')).toBe(true);
+ expect(isScriptUrl('vbscript:x')).toBe(true);
+ expect(isScriptUrl('')).toBe(true);
+ expect(isScriptUrl(undefined)).toBe(true);
+ });
+
+ test('lets app deep links and web links through', () => {
+ expect(isScriptUrl('myapp://settings')).toBe(false);
+ expect(isScriptUrl('https://example.com')).toBe(false);
+ expect(isScriptUrl('/relative')).toBe(false);
+ });
+});
+
+describe('originMatchesUrl', () => {
+ test('matches the exact origin of the configured URL', () => {
+ expect(originMatchesUrl('https://outboundmedia.gleap.io', 'https://outboundmedia.gleap.io')).toBe(true);
+ expect(originMatchesUrl('https://outboundmedia.gleap.io', 'https://outboundmedia.gleap.io/modal')).toBe(true);
+ });
+
+ test('rejects prefixes and look-alikes that a substring check accepted', () => {
+ expect(originMatchesUrl('https://outboundmedia.gl', 'https://outboundmedia.gleap.io')).toBe(false);
+ expect(originMatchesUrl('https://outboundmedia.gleap', 'https://outboundmedia.gleap.io/modal')).toBe(false);
+ expect(originMatchesUrl('https://outboundmedia', 'https://outboundmedia.gleap.io')).toBe(false);
+ expect(originMatchesUrl('https://', 'https://outboundmedia.gleap.io')).toBe(false);
+ expect(originMatchesUrl('null', 'https://outboundmedia.gleap.io')).toBe(false);
+ expect(originMatchesUrl('', 'https://outboundmedia.gleap.io')).toBe(false);
+ expect(originMatchesUrl('https://outboundmedia.gleap.io', undefined)).toBe(false);
+ });
+});
+
+describe('frameTargetOrigin', () => {
+ test('pins a same-origin (about:blank bootstrapped) frame to our origin', () => {
+ const frame = document.createElement('iframe');
+ document.body.appendChild(frame);
+ expect(frameTargetOrigin(frame, 'https://messenger-app.gleap.io')).toBe(window.location.origin);
+ });
+
+ test('pins a cross-origin (src fallback) frame to the frame URL origin', () => {
+ const frame = {
+ contentWindow: {
+ get location() {
+ return {
+ get origin() {
+ throw new Error('SecurityError');
+ },
+ };
+ },
+ },
+ };
+ expect(frameTargetOrigin(frame, 'https://messenger-app.gleap.io/chatbar')).toBe('https://messenger-app.gleap.io');
+ });
+
+ test("falls back to '*' for opaque or unknown origins", () => {
+ expect(frameTargetOrigin({ contentWindow: { location: { origin: 'null' } } }, 'https://x.example')).toBe('*');
+ expect(frameTargetOrigin({}, 'not a url')).toBe('*');
+ });
+});
diff --git a/src/GleapModalManager.js b/src/GleapModalManager.js
index 2f72feaa..21772357 100644
--- a/src/GleapModalManager.js
+++ b/src/GleapModalManager.js
@@ -1,6 +1,7 @@
import Gleap, { GleapConfigManager, GleapFrameManager } from './Gleap';
import { isOutboundActionBlocked } from './GleapCaptureSettings';
import { bootstrapGleapFrame } from './GleapHelper';
+import { originMatchesUrl } from './GleapMessageSecurity';
// How long the card renderer gets to announce itself (`modal-loaded`) before we assume the
// bootstrapped frame is dead and load it directly — and, after a second period, give up.
@@ -57,7 +58,7 @@ export default class GleapModalManager {
? this.modalContainer.querySelector('.gleap-modal-frame')
: null;
const sourceMatches = modalFrame && event.source === modalFrame.contentWindow;
- const originMatches = this.modalUrl?.includes(event.origin);
+ const originMatches = originMatchesUrl(event.origin, this.modalUrl);
if (!sourceMatches && !originMatches) {
return;
}