From 93948d5b54ba7c8fa792bd6716bdd05c25cdfdde Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 13 Oct 2026 21:11:25 +0200 Subject: [PATCH 1/4] fix(network-logs): keep the SDK's calls through a custom api url out of the logs Only gleap.io / gleap.ai were blacklisted, so with Gleap.setApiUrl every SDK call (capture uploads, /logs, session updates) was recorded with the widget session credential and stored on the ticket. The custom api url is now blacklisted like api.gleap.io, and Gleap-Hash is always masked. Co-Authored-By: Claude Opus 5.5 --- src/Gleap.js | 2 ++ src/GleapNetworkIntercepter.js | 13 ++++++++++++- src/GleapNetworkLogSanitizer.js | 18 +++++++++++++++-- src/GleapNetworkLogSanitizer.test.js | 29 ++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/Gleap.js b/src/Gleap.js index 4b6bdbb7..83908a06 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -717,6 +717,8 @@ class Gleap { */ static setApiUrl(apiUrl) { GleapSession.getInstance().apiUrl = apiUrl; + // The SDK's own calls (with the session credential) stay out of the network logs. + GleapNetworkIntercepter.getInstance().setSdkApiUrl(apiUrl); } /** diff --git a/src/GleapNetworkIntercepter.js b/src/GleapNetworkIntercepter.js index 1be126da..3d136fbf 100644 --- a/src/GleapNetworkIntercepter.js +++ b/src/GleapNetworkIntercepter.js @@ -1,5 +1,6 @@ import { serializeBounded } from './GleapLogFormatter'; import { + apiUrlBlacklistEntry, BODY_NOT_CAPTURED, DEFAULT_NETWORK_LOG_BLACKLIST, isBlacklistedUrl, @@ -506,6 +507,7 @@ class GleapNetworkIntercepter { localFilters = []; remoteFilters = []; localBlacklist = []; + sdkApiUrlEntry = null; remoteBlacklist = []; initialized = false; stopped = false; @@ -709,8 +711,17 @@ class GleapNetworkIntercepter { return normalizeStringList(this.remoteFilters.concat(this.localFilters)); } + /** + * A custom api url (Gleap.setApiUrl): the SDK's own calls through it are left out like api.gleap.io. + */ + setSdkApiUrl(apiUrl) { + this.sdkApiUrlEntry = apiUrlBlacklistEntry(apiUrl); + } + getBlacklist() { - return normalizeStringList(DEFAULT_NETWORK_LOG_BLACKLIST.concat(this.remoteBlacklist, this.localBlacklist)); + return normalizeStringList( + DEFAULT_NETWORK_LOG_BLACKLIST.concat(this.sdkApiUrlEntry ? [this.sdkApiUrlEntry] : [], this.remoteBlacklist, this.localBlacklist) + ); } isCapturing() { diff --git a/src/GleapNetworkLogSanitizer.js b/src/GleapNetworkLogSanitizer.js index 6154ad9f..2700e903 100644 --- a/src/GleapNetworkLogSanitizer.js +++ b/src/GleapNetworkLogSanitizer.js @@ -6,8 +6,9 @@ export const DEFAULT_NETWORK_LOG_BLACKLIST = ['gleap.io', 'gleap.ai']; export const REDACTED_VALUE = '[REDACTED]'; export const BODY_NOT_CAPTURED = '[body not captured]'; -// Credentials are masked (key kept, value replaced) whatever the project settings say. -const ALWAYS_MASKED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'set-cookie']; +// Credentials are masked (key kept, value replaced) whatever the project settings say. Gleap-Hash is +// the widget session's credential (on the SDK's own calls through a custom api url). +const ALWAYS_MASKED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'set-cookie', 'gleap-hash']; // Guards the recursive walks against pathological nesting. A body nested deeper than this is // dropped instead of being sent unredacted. @@ -42,6 +43,19 @@ export const normalizeStringList = (list) => { return result; }; +/** + * The blacklist entry for a custom api url (Gleap.setApiUrl): the SDK's own calls through a proxy + * stay out of the logs like api.gleap.io. The whole url (origin and path) so the app's other + * requests to the same host stay in. Null for anything that isn't an http(s) url. + */ +export const apiUrlBlacklistEntry = (apiUrl) => { + if (typeof apiUrl !== 'string') { + return null; + } + const trimmed = apiUrl.trim().replace(/\/+$/, ''); + return /^https?:\/\/[^/\s]+/i.test(trimmed) ? trimmed : null; +}; + /** * True when the url contains any blacklist entry (substring match). */ diff --git a/src/GleapNetworkLogSanitizer.test.js b/src/GleapNetworkLogSanitizer.test.js index 62d84f58..867e5bf6 100644 --- a/src/GleapNetworkLogSanitizer.test.js +++ b/src/GleapNetworkLogSanitizer.test.js @@ -246,3 +246,32 @@ describe('GleapNetworkIntercepter redaction settings', () => { expect(intercepter.requests[1].request.payload).toBe('{"token":"a","id":1}'); }); }); + +describe('custom api url (Gleap.setApiUrl)', () => { + test("leaves the SDK's own calls through the proxy out, keeps the app's other requests to that host", () => { + const intercepter = new GleapNetworkIntercepter(); + intercepter.setSdkApiUrl('https://app.example.com/gleap-api/'); + intercepter.requests = { + 1: entry({ url: 'https://app.example.com/gleap-api/sessions/partialupdate' }), + 2: entry({ url: 'https://app.example.com/gleap-api/uploads/attachments' }), + 3: entry({ url: 'https://app.example.com/api/login' }), + }; + + expect(intercepter.getRequests().map((log) => log.url)).toEqual(['https://app.example.com/api/login']); + expect(intercepter.createRequest('POST', 'https://app.example.com/gleap-api/v3/shared/capture-requests/1/logs')).toBeNull(); + }); + + test('anything but an http(s) url adds no blacklist entry', () => { + const intercepter = new GleapNetworkIntercepter(); + intercepter.setSdkApiUrl(''); + intercepter.setSdkApiUrl(undefined); + expect(intercepter.getBlacklist()).toEqual(['gleap.io', 'gleap.ai']); + }); + + test('masks the widget session credential (Gleap-Hash) on any request', () => { + const log = entry({ + request: { headers: { 'Gleap-Hash': 'h-123', 'Gleap-Id': 'g-1', Accept: '*/*' }, payload: '' }, + }); + expect(sanitizeOne(log).request.headers).toEqual({ 'Gleap-Hash': REDACTED_VALUE, 'Gleap-Id': 'g-1', Accept: '*/*' }); + }); +}); From 1ba664f7854223a98192e5a7ea41e3bb43552ebe Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 13 Oct 2026 21:11:25 +0200 Subject: [PATCH 2/4] fix(capture): send capture logs only once the capture is saved, without paused stretches Screenshot logs were posted when the shutter clicked, so a cancelled screenshot still put the customer's console and network logs on the ticket, and a retake kept the first take's logs. They are now held until the Messenger reports the screenshot saved (capture-done sent: true); recording logs go after /complete succeeds (as the native SDKs do). Recording logs also leave out the stretches the customer paused (Pause is for logging in or paying). Co-Authored-By: Claude Opus 5.5 --- src/GleapCaptureApi.js | 20 +++++++++++------- src/GleapCaptureLogs.test.js | 32 +++++++++++++++++++++++++++++ src/GleapCaptureManager.js | 39 ++++++++++++++++++++++++------------ 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/GleapCaptureApi.js b/src/GleapCaptureApi.js index c9a93e63..d70da71a 100644 --- a/src/GleapCaptureApi.js +++ b/src/GleapCaptureApi.js @@ -243,17 +243,23 @@ const entryTime = (entry) => { return typeof value === 'string' || typeof value === 'number' ? new Date(value).getTime() : NaN; }; -const withinWindow = (entries, from, to) => { +// [from, to] wall times a recording was paused (to = 0: paused until the end). +const inPause = (time, pauses) => + pauses.some((pause) => Array.isArray(pause) && time >= pause[0] && (!pause[1] || time < pause[1])); + +const withinWindow = (entries, from, to, pauses) => { if (!Array.isArray(entries)) { return []; } if (!from) { return entries.slice(); } + const paused = Array.isArray(pauses) ? pauses : []; return entries.filter((entry) => { const time = entryTime(entry); // Entries without a usable date stay in. - return isNaN(time) || (time >= from && time <= to); + // Paused stretches stay private: Pause is for logging in or paying. + return isNaN(time) || (time >= from && time <= to && !inPause(time, paused)); }); }; @@ -269,8 +275,8 @@ const webReplaysEnabled = () => { /** * The log bundle for /logs (contract §9): the data a bug report carries, from the same collectors, * limited to what `include` asks for. Never takes a screenshot. With a recording window only the - * entries of that window (and a short lead-in) are included. - * @param {{include?: object, windowStart?: number, windowEnd?: number, deviceId?: string}} options + * entries of that window (and a short lead-in) are included, without the stretches it was paused. + * @param {{include?: object, windowStart?: number, windowEnd?: number, pauses?: Array<[number, number]>, deviceId?: string}} options */ export const buildLogsBundle = (options = {}) => { const include = normalizeInclude(options.include); @@ -286,10 +292,10 @@ export const buildLogsBundle = (options = {}) => { }; if (include.consoleLog) { - collect('consoleLog', () => withinWindow(GleapConsoleLogManager.getInstance().getLogs(), filterFrom, windowEnd)); + collect('consoleLog', () => withinWindow(GleapConsoleLogManager.getInstance().getLogs(), filterFrom, windowEnd, options.pauses)); } if (include.networkLogs) { - collect('networkLogs', () => withinWindow(GleapNetworkIntercepter.getInstance().getRequests(), filterFrom, windowEnd)); + collect('networkLogs', () => withinWindow(GleapNetworkIntercepter.getInstance().getRequests(), filterFrom, windowEnd, options.pauses)); } if (include.customData) { collect('customData', () => GleapCustomDataManager.getInstance().getCustomData()); @@ -298,7 +304,7 @@ export const buildLogsBundle = (options = {}) => { collect('metaData', () => GleapMetaDataManager.getInstance().getMetaData()); } if (include.customEventLog) { - collect('customEventLog', () => withinWindow(GleapStreamedEvent.getInstance().getEventArray(), filterFrom, windowEnd)); + collect('customEventLog', () => withinWindow(GleapStreamedEvent.getInstance().getEventArray(), filterFrom, windowEnd, options.pauses)); } if (include.replays && webReplaysEnabled()) { try { diff --git a/src/GleapCaptureLogs.test.js b/src/GleapCaptureLogs.test.js index 9af3a33a..edde2049 100644 --- a/src/GleapCaptureLogs.test.js +++ b/src/GleapCaptureLogs.test.js @@ -84,6 +84,13 @@ describe('log bundle', () => { expect(bundle.windowEnd).toBe(iso(60000)); }); + test('a paused stretch of the recording sends none of its entries', () => { + const bundle = buildLogsBundle({ windowStart: 50000, windowEnd: 60000, pauses: [[51500, 53000]] }); + expect(bundle.consoleLog.map((entry) => entry.log)).toEqual(['during']); + expect(bundle.networkLogs).toEqual([]); + expect(bundle.customEventLog.map((entry) => entry.name)).toEqual(['pageView']); + }); + test('encoded in slices to valid JSON: the replay packed, data that cannot be serialized left out', async () => { const bundle = buildLogsBundle({ include: { replays: true } }); const cyclic = { plan: 'pro' }; @@ -207,6 +214,31 @@ describe('log requests from the websocket', () => { expect(api.postCaptureLogs).toHaveBeenCalledTimes(1); }); + test("a screenshot's logs go only once the Messenger saved it: never for a cancel or a skip", async () => { + const image = { dataUrl: 'data:image/jpeg;base64,AA==', width: 1, height: 1, method: 'dom' }; + const screenshot = (requestId) => { + const session = { requestId, kind: 'screenshot', options: { attachLogs: true }, phase: 'capturing', resumed: false }; + manager.session = session; + manager.onScreenshotReady(session, image, { consoleLog: [{ log: requestId }] }); + }; + + screenshot('cr-9'); + await flush(); + expect(api.postCaptureLogs).not.toHaveBeenCalled(); + manager.onCaptureDone({ requestId: 'cr-9', sent: true }); + await flush(); + expect(api.postCaptureLogs).toHaveBeenCalledTimes(1); + expect(api.postCaptureLogs.mock.calls[0][0]).toBe('cr-9'); + + screenshot('cr-10'); + manager.onCaptureCancel({ requestId: 'cr-10' }); + manager.onCaptureDone({ requestId: 'cr-10', sent: true }); + screenshot('cr-11'); + manager.onCaptureDone({ requestId: 'cr-11', sent: false }); + await flush(); + expect(api.postCaptureLogs).toHaveBeenCalledTimes(1); + }); + test('ignores other kinds, expired requests and requests that are already final', async () => { manager.handleServerRequest({ id: 'cr-5', kind: 'screenshot' }); manager.handleServerRequest({ id: 'cr-6', kind: 'logs', expiresAt: iso(Date.now() - 1000) }); diff --git a/src/GleapCaptureManager.js b/src/GleapCaptureManager.js index e28d5245..7eaf888e 100644 --- a/src/GleapCaptureManager.js +++ b/src/GleapCaptureManager.js @@ -362,6 +362,12 @@ export default class GleapCaptureManager { onCaptureDone(data) { const session = this.session; if (session && (!data.requestId || data.requestId === session.requestId)) { + // The screenshot's logs go only once the Messenger saved it (`sent: false`: skipped, failed or + // answered elsewhere; older Messengers don't say and still get them). + if (session.pendingLogs && data.sent !== false) { + this.postLogs(session.requestId, session.pendingLogs); + } + session.pendingLogs = null; this.endSession(session, { restore: true }); } this.setEditorOpen(false); @@ -760,9 +766,9 @@ export default class GleapCaptureManager { frameManager.setCaptureHidden(false, true); } - if (logsBundle) { - this.postLogs(session.requestId, logsBundle); - } + // Held until the customer sends it (capture-done): a cancelled screenshot sends no logs, and a + // retake replaces them. + session.pendingLogs = logsBundle || null; } postLogs(requestId, bundle) { @@ -992,6 +998,10 @@ export default class GleapCaptureManager { this.liftVeil(session); session.recording = null; session.result = result; + // When it was paused: the logs leave those stretches out (Pause is for logging in or paying). + session.pauses = session.timeline + ? session.timeline.pauses.map((pause) => [pause[0], pause[1] || result.endedAt]) + : []; session.timelineEntries = session.timeline ? session.timeline.stop(result.endedAt) : []; const interactions = session.timeline ? session.timeline.interactions : 0; session.timeline = null; @@ -1015,6 +1025,7 @@ export default class GleapCaptureManager { session.result = null; session.uploadedUrl = null; session.timelineEntries = null; + session.pauses = null; session.phase = 'bar'; session.interrupted = false; this.renderBar(session); @@ -1032,18 +1043,17 @@ export default class GleapCaptureManager { this.ui.setUploading(0); this.sendState(session.requestId, 'uploading', { progress: 0 }); - // Logs for the recording window go along (in parallel with the upload). + // Logs for the recording window (without its paused stretches), sent once the recording is saved. + let logsBundle = null; if (this.attachesLogs(session)) { try { - this.postLogs( - session.requestId, - buildLogsBundle({ - include: session.options.include, - windowStart: result.startedAt, - windowEnd: result.endedAt, - deviceId: this.getDeviceId(), - }) - ); + logsBundle = buildLogsBundle({ + include: session.options.include, + windowStart: result.startedAt, + windowEnd: result.endedAt, + pauses: session.pauses || [], + deviceId: this.getDeviceId(), + }); } catch (exp) {} } @@ -1103,6 +1113,9 @@ export default class GleapCaptureManager { return; } if (isOk(response.status)) { + if (logsBundle) { + this.postLogs(session.requestId, logsBundle); + } // What the card shows (and where its flight starts), read before the review closes. const poster = this.ui ? this.ui.getPosterDataUrl() : null; const from = this.ui ? this.ui.getPreviewRect() : null; From dba931bd95785216c9335e14d2419a070e9380d1 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 13 Oct 2026 21:14:18 +0200 Subject: [PATCH 3/4] 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 4/4] 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; }