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/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/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;
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;
}
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: '*/*' });
+ });
+});