Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/GleapAgentConversation.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -141,23 +142,23 @@ export const registerGleapAgentComponents = () => {

this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-data', data: config }),
'*'
frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}

sendMessage(content) {
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)
);
}

clearConversation() {
if (!this._iframe?.contentWindow) return;
this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-clear', data: {} }),
'*'
frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/GleapAiChatbarManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {}
}

Expand Down
3 changes: 2 additions & 1 deletion src/GleapBannerManager.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
27 changes: 26 additions & 1 deletion src/GleapBannerManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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();
});
});
37 changes: 25 additions & 12 deletions src/GleapFrameManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
};

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1420,7 +1433,7 @@ export default class GleapFrameManager {
name: 'collect-ticket-data',
data: ticketData,
}),
'*'
frameTargetOrigin(targetFrame, this.frameUrl)
);
}
} catch (e) {}
Expand Down
45 changes: 45 additions & 0 deletions src/GleapFrameManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>alert(1)</script>', 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);
});
});
91 changes: 91 additions & 0 deletions src/GleapMessageSecurity.js
Original file line number Diff line number Diff line change
@@ -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 '*';
}
};
Loading
Loading