Skip to content
Merged
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
16 changes: 12 additions & 4 deletions src/GleapAdminManager.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
}
Expand Down Expand Up @@ -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) {}
Expand All @@ -244,7 +252,7 @@ export default class GleapAdminManager {
...data,
type: 'tourbuilder',
}),
'*'
resolveGleapAdminOrigin(this.adminOrigin)
);
}
} catch (e) {}
Expand Down Expand Up @@ -309,7 +317,7 @@ export default class GleapAdminManager {
// Inject widget HTML.
var elem = document.createElement('div');
elem.className = 'gleap-admin-frame-container';
elem.innerHTML = `<iframe src="https://app.gleap.io/${
elem.innerHTML = `<iframe src="${resolveGleapAdminOrigin(this.adminOrigin)}/${
this?.configData?.type === 'tooltips' ? 'tooltipbuilder' : 'producttourbuilder'
}" class="gleap-admin-frame" scrolling="no" title="Gleap Admin Window" allow="autoplay; encrypted-media; fullscreen;" frameborder="0"></iframe>`;
document.body.appendChild(elem);
Expand Down
24 changes: 24 additions & 0 deletions src/GleapAdminOrigins.js
Original file line number Diff line number Diff line change
@@ -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);
109 changes: 109 additions & 0 deletions src/GleapAdminOrigins.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});