diff --git a/src/GleapAdminManager.js b/src/GleapAdminManager.js index 5e548258..a85674cd 100644 --- a/src/GleapAdminManager.js +++ b/src/GleapAdminManager.js @@ -1,6 +1,7 @@ import { loadIcon } from './UI'; import GleapAdminHelper from './GleapAdminHelper'; import Gleap, { GleapModalManager, GleapProductTours, GleapBannerManager } from './Gleap'; +import { isGleapAdminOrigin, resolveGleapAdminOrigin } from './GleapAdminOrigins'; export default class GleapAdminManager { libraryInstance = null; @@ -14,6 +15,8 @@ export default class GleapAdminManager { adminHelper = null; status = 'navigate'; initialized = false; + // Dashboard origin that opened the admin session (set from the validated `load` message). + adminOrigin = null; // GleapAdminManager singleton static instance; @@ -105,7 +108,7 @@ export default class GleapAdminManager { // Add window message listener. window.addEventListener('message', (event) => { - if (event.origin !== 'https://app.gleap.io') { + if (!isGleapAdminOrigin(event.origin)) { return; } @@ -114,6 +117,9 @@ export default class GleapAdminManager { if (data.type === 'admin') { if (data.name === 'load') { + if (!self.adminOrigin) { + self.adminOrigin = resolveGleapAdminOrigin(event.origin); + } self.configData = data.data; self.initAdminHelper(); } @@ -230,7 +236,9 @@ export default class GleapAdminManager { ...data, type: 'admin', }), - '*' + // `init` goes out before the dashboard origin is known; everything after `load` is + // only delivered to the dashboard that opened the session. + this.adminOrigin || '*' ); } } catch (e) {} @@ -244,7 +252,7 @@ export default class GleapAdminManager { ...data, type: 'tourbuilder', }), - '*' + resolveGleapAdminOrigin(this.adminOrigin) ); } } catch (e) {} @@ -309,7 +317,7 @@ export default class GleapAdminManager { // Inject widget HTML. var elem = document.createElement('div'); elem.className = 'gleap-admin-frame-container'; - elem.innerHTML = ``; document.body.appendChild(elem); diff --git a/src/GleapAdminOrigins.js b/src/GleapAdminOrigins.js new file mode 100644 index 00000000..13a08215 --- /dev/null +++ b/src/GleapAdminOrigins.js @@ -0,0 +1,24 @@ +/** + * Dashboard origins that may drive the product tour / tooltip builder (admin mode). + * + * The dashboard moved from app.gleap.io to app.gleap.ai; both serve the same app. + * Exact string match only: never a suffix or wildcard check, which `evilgleap.ai` + * or `app.gleap.ai.evil.com` could pass. + */ +export const GLEAP_ADMIN_ORIGINS = Object.freeze(['https://app.gleap.ai', 'https://app.gleap.io']); + +export const GLEAP_DEFAULT_ADMIN_ORIGIN = 'https://app.gleap.ai'; + +/** + * @param {*} origin - a MessageEvent origin. + * @returns {boolean} true only for an exact allowlisted dashboard origin. + */ +export const isGleapAdminOrigin = (origin) => typeof origin === 'string' && GLEAP_ADMIN_ORIGINS.indexOf(origin) !== -1; + +/** + * The origin the builder iframe is loaded from: the dashboard origin that opened the + * admin session, falling back to the default dashboard. + * @param {*} origin - the origin of the validated opener message. + * @returns {string} + */ +export const resolveGleapAdminOrigin = (origin) => (isGleapAdminOrigin(origin) ? origin : GLEAP_DEFAULT_ADMIN_ORIGIN); diff --git a/src/GleapAdminOrigins.test.js b/src/GleapAdminOrigins.test.js new file mode 100644 index 00000000..323710db --- /dev/null +++ b/src/GleapAdminOrigins.test.js @@ -0,0 +1,109 @@ +/** + * @jest-environment jsdom + */ +import GleapAdminManager from './GleapAdminManager'; +import { + GLEAP_ADMIN_ORIGINS, + GLEAP_DEFAULT_ADMIN_ORIGIN, + isGleapAdminOrigin, + resolveGleapAdminOrigin, +} from './GleapAdminOrigins'; + +const LOOKALIKES = [ + 'https://app.gleap.ai.evil.com', + 'https://app.gleap.io.evil.com', + 'https://evilgleap.ai', + 'https://evilgleap.io', + 'https://evil.app.gleap.ai', + 'https://gleap.ai', + 'https://app.gleap.ai:8443', + 'http://app.gleap.ai', + 'http://app.gleap.io', + 'https://APP.GLEAP.AI', + 'https://app.gleap.ai/', + 'null', + '', + undefined, + null, + ['https://app.gleap.ai'], +]; + +describe('admin origin allowlist', () => { + it('is exactly the two dashboard origins', () => { + expect([...GLEAP_ADMIN_ORIGINS].sort()).toEqual(['https://app.gleap.ai', 'https://app.gleap.io']); + expect(GLEAP_DEFAULT_ADMIN_ORIGIN).toBe('https://app.gleap.ai'); + }); + + it('accepts both dashboard hosts', () => { + expect(isGleapAdminOrigin('https://app.gleap.ai')).toBe(true); + expect(isGleapAdminOrigin('https://app.gleap.io')).toBe(true); + }); + + it.each(LOOKALIKES)('rejects %p', (origin) => { + expect(isGleapAdminOrigin(origin)).toBe(false); + expect(resolveGleapAdminOrigin(origin)).toBe(GLEAP_DEFAULT_ADMIN_ORIGIN); + }); + + it('keeps the opener origin for the builder', () => { + expect(resolveGleapAdminOrigin('https://app.gleap.io')).toBe('https://app.gleap.io'); + expect(resolveGleapAdminOrigin('https://app.gleap.ai')).toBe('https://app.gleap.ai'); + }); +}); + +describe('GleapAdminManager message origin', () => { + let manager; + let listeners; + + const post = (origin, payload) => + window.dispatchEvent(new MessageEvent('message', { origin, data: JSON.stringify(payload) })); + + const load = (origin, type = 'tours') => post(origin, { type: 'admin', name: 'load', data: { type } }); + + beforeEach(() => { + document.body.innerHTML = ''; + GleapAdminManager.instance = undefined; + manager = GleapAdminManager.getInstance(); + // Keep the admin UI side effects out of the origin check. + manager.initAdminHelper = jest.fn(() => manager.injectFrame()); + manager.startPageListener = jest.fn(); + listeners = []; + const add = window.addEventListener.bind(window); + jest.spyOn(window, 'addEventListener').mockImplementation((type, fn, opts) => { + if (type === 'message') listeners.push(fn); + return add(type, fn, opts); + }); + manager.start(); + }); + + afterEach(() => { + listeners.forEach((fn) => window.removeEventListener('message', fn)); + jest.restoreAllMocks(); + }); + + it.each(['https://app.gleap.ai.evil.com', 'https://evilgleap.ai', 'https://example.com'])( + 'ignores a load from %s', + (origin) => { + load(origin); + expect(manager.initAdminHelper).not.toHaveBeenCalled(); + expect(manager.adminOrigin).toBeNull(); + expect(document.querySelector('.gleap-admin-frame')).toBeNull(); + } + ); + + it.each([ + ['https://app.gleap.ai', 'tours', 'https://app.gleap.ai/producttourbuilder'], + ['https://app.gleap.io', 'tooltips', 'https://app.gleap.io/tooltipbuilder'], + ])('loads the builder from the opener origin %s', (origin, type, src) => { + load(origin, type); + expect(manager.initAdminHelper).toHaveBeenCalledTimes(1); + expect(manager.adminOrigin).toBe(origin); + expect(document.querySelector('.gleap-admin-frame').getAttribute('src')).toBe(src); + }); + + it('defaults the builder to app.gleap.ai', () => { + manager.injectFrame(); + expect(document.querySelector('.gleap-admin-frame').getAttribute('src')).toBe( + 'https://app.gleap.ai/producttourbuilder' + ); + }); +});