From dba931bd95785216c9335e14d2419a070e9380d1 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 13 Oct 2026 21:14:18 +0200 Subject: [PATCH 1/2] fix(security): default url handler opens only http(s), mailto, tel and relative links The default urlHandler set window.location.href to whatever the widget, a banner, a modal, the chatbar or Gleap.openURL handed it, so a javascript: URL ran in the customer's page. It now opens only safe schemes, new tabs with noopener,noreferrer. A custom Gleap.setUrlHandler handler still gets app deep links but never javascript:/vbscript:/data:. Messages to the Messenger frame are pinned to its origin. Co-Authored-By: Claude Opus 5.5 --- src/GleapFrameManager.js | 37 ++++++++----- src/GleapFrameManager.test.js | 45 ++++++++++++++++ src/GleapMessageSecurity.js | 91 +++++++++++++++++++++++++++++++ src/GleapMessageSecurity.test.js | 93 ++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 src/GleapMessageSecurity.js create mode 100644 src/GleapMessageSecurity.test.js 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('*'); + }); +}); From 7de87c95d80b73c43715a9c0110c56f7e9d4d5e9 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 13 Oct 2026 21:14:18 +0200 Subject: [PATCH 2/2] fix(security): exact origin checks for banner/modal, pinned targetOrigin for chatbar and agent conversation Banner and modal listeners matched event.origin with bannerUrl.includes(), so https://outboundmedia.gl passed for https://outboundmedia.gleap.io and could trigger open-url, custom actions and tours. Now exact origin equality. Chatbar and (which receives gleapId and gleapHash) post to their frame's origin instead of '*'. Co-Authored-By: Claude Opus 5.5 --- src/GleapAgentConversation.js | 7 ++++--- src/GleapAiChatbarManager.js | 6 +++++- src/GleapBannerManager.js | 3 ++- src/GleapBannerManager.test.js | 27 ++++++++++++++++++++++++++- src/GleapModalManager.js | 3 ++- 5 files changed, 39 insertions(+), 7 deletions(-) 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/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; }