Skip to content

Commit 4606f5b

Browse files
boehlerlukasclaude
andcommitted
fix: accept the app.gleap.ai dashboard in the admin builder
The dashboard moved to app.gleap.ai. The product tour / tooltip builder only accepted messages from https://app.gleap.io and always loaded its iframe from app.gleap.io, so the visual editor broke for teams on app.gleap.ai. - Admin/builder messages are accepted from an exact allowlist (https://app.gleap.ai, https://app.gleap.io); no suffix or wildcard match. - The builder iframe loads from the origin of the validated `load` message, defaulting to https://app.gleap.ai. - Messages to the opener (after `load`) and to the builder iframe are posted to that origin instead of '*'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 392cd34 commit 4606f5b

3 files changed

Lines changed: 145 additions & 4 deletions

File tree

‎src/GleapAdminManager.js‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { loadIcon } from './UI';
22
import GleapAdminHelper from './GleapAdminHelper';
33
import Gleap, { GleapModalManager, GleapProductTours, GleapBannerManager } from './Gleap';
4+
import { isGleapAdminOrigin, resolveGleapAdminOrigin } from './GleapAdminOrigins';
45

56
export default class GleapAdminManager {
67
libraryInstance = null;
@@ -14,6 +15,8 @@ export default class GleapAdminManager {
1415
adminHelper = null;
1516
status = 'navigate';
1617
initialized = false;
18+
// Dashboard origin that opened the admin session (set from the validated `load` message).
19+
adminOrigin = null;
1720

1821
// GleapAdminManager singleton
1922
static instance;
@@ -105,7 +108,7 @@ export default class GleapAdminManager {
105108

106109
// Add window message listener.
107110
window.addEventListener('message', (event) => {
108-
if (event.origin !== 'https://app.gleap.io') {
111+
if (!isGleapAdminOrigin(event.origin)) {
109112
return;
110113
}
111114

@@ -114,6 +117,9 @@ export default class GleapAdminManager {
114117

115118
if (data.type === 'admin') {
116119
if (data.name === 'load') {
120+
if (!self.adminOrigin) {
121+
self.adminOrigin = resolveGleapAdminOrigin(event.origin);
122+
}
117123
self.configData = data.data;
118124
self.initAdminHelper();
119125
}
@@ -230,7 +236,9 @@ export default class GleapAdminManager {
230236
...data,
231237
type: 'admin',
232238
}),
233-
'*'
239+
// `init` goes out before the dashboard origin is known; everything after `load` is
240+
// only delivered to the dashboard that opened the session.
241+
this.adminOrigin || '*'
234242
);
235243
}
236244
} catch (e) {}
@@ -244,7 +252,7 @@ export default class GleapAdminManager {
244252
...data,
245253
type: 'tourbuilder',
246254
}),
247-
'*'
255+
resolveGleapAdminOrigin(this.adminOrigin)
248256
);
249257
}
250258
} catch (e) {}
@@ -309,7 +317,7 @@ export default class GleapAdminManager {
309317
// Inject widget HTML.
310318
var elem = document.createElement('div');
311319
elem.className = 'gleap-admin-frame-container';
312-
elem.innerHTML = `<iframe src="https://app.gleap.io/${
320+
elem.innerHTML = `<iframe src="${resolveGleapAdminOrigin(this.adminOrigin)}/${
313321
this?.configData?.type === 'tooltips' ? 'tooltipbuilder' : 'producttourbuilder'
314322
}" class="gleap-admin-frame" scrolling="no" title="Gleap Admin Window" allow="autoplay; encrypted-media; fullscreen;" frameborder="0"></iframe>`;
315323
document.body.appendChild(elem);

‎src/GleapAdminOrigins.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Dashboard origins that may drive the product tour / tooltip builder (admin mode).
3+
*
4+
* The dashboard moved from app.gleap.io to app.gleap.ai; both serve the same app.
5+
* Exact string match only: never a suffix or wildcard check, which `evilgleap.ai`
6+
* or `app.gleap.ai.evil.com` could pass.
7+
*/
8+
export const GLEAP_ADMIN_ORIGINS = Object.freeze(['https://app.gleap.ai', 'https://app.gleap.io']);
9+
10+
export const GLEAP_DEFAULT_ADMIN_ORIGIN = 'https://app.gleap.ai';
11+
12+
/**
13+
* @param {*} origin - a MessageEvent origin.
14+
* @returns {boolean} true only for an exact allowlisted dashboard origin.
15+
*/
16+
export const isGleapAdminOrigin = (origin) => typeof origin === 'string' && GLEAP_ADMIN_ORIGINS.indexOf(origin) !== -1;
17+
18+
/**
19+
* The origin the builder iframe is loaded from: the dashboard origin that opened the
20+
* admin session, falling back to the default dashboard.
21+
* @param {*} origin - the origin of the validated opener message.
22+
* @returns {string}
23+
*/
24+
export const resolveGleapAdminOrigin = (origin) => (isGleapAdminOrigin(origin) ? origin : GLEAP_DEFAULT_ADMIN_ORIGIN);

‎src/GleapAdminOrigins.test.js‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import GleapAdminManager from './GleapAdminManager';
5+
import {
6+
GLEAP_ADMIN_ORIGINS,
7+
GLEAP_DEFAULT_ADMIN_ORIGIN,
8+
isGleapAdminOrigin,
9+
resolveGleapAdminOrigin,
10+
} from './GleapAdminOrigins';
11+
12+
const LOOKALIKES = [
13+
'https://app.gleap.ai.evil.com',
14+
'https://app.gleap.io.evil.com',
15+
'https://evilgleap.ai',
16+
'https://evilgleap.io',
17+
'https://evil.app.gleap.ai',
18+
'https://gleap.ai',
19+
'https://app.gleap.ai:8443',
20+
'http://app.gleap.ai',
21+
'http://app.gleap.io',
22+
'https://APP.GLEAP.AI',
23+
'https://app.gleap.ai/',
24+
'null',
25+
'',
26+
undefined,
27+
null,
28+
['https://app.gleap.ai'],
29+
];
30+
31+
describe('admin origin allowlist', () => {
32+
it('is exactly the two dashboard origins', () => {
33+
expect([...GLEAP_ADMIN_ORIGINS].sort()).toEqual(['https://app.gleap.ai', 'https://app.gleap.io']);
34+
expect(GLEAP_DEFAULT_ADMIN_ORIGIN).toBe('https://app.gleap.ai');
35+
});
36+
37+
it('accepts both dashboard hosts', () => {
38+
expect(isGleapAdminOrigin('https://app.gleap.ai')).toBe(true);
39+
expect(isGleapAdminOrigin('https://app.gleap.io')).toBe(true);
40+
});
41+
42+
it.each(LOOKALIKES)('rejects %p', (origin) => {
43+
expect(isGleapAdminOrigin(origin)).toBe(false);
44+
expect(resolveGleapAdminOrigin(origin)).toBe(GLEAP_DEFAULT_ADMIN_ORIGIN);
45+
});
46+
47+
it('keeps the opener origin for the builder', () => {
48+
expect(resolveGleapAdminOrigin('https://app.gleap.io')).toBe('https://app.gleap.io');
49+
expect(resolveGleapAdminOrigin('https://app.gleap.ai')).toBe('https://app.gleap.ai');
50+
});
51+
});
52+
53+
describe('GleapAdminManager message origin', () => {
54+
let manager;
55+
let listeners;
56+
57+
const post = (origin, payload) =>
58+
window.dispatchEvent(new MessageEvent('message', { origin, data: JSON.stringify(payload) }));
59+
60+
const load = (origin, type = 'tours') => post(origin, { type: 'admin', name: 'load', data: { type } });
61+
62+
beforeEach(() => {
63+
document.body.innerHTML = '';
64+
GleapAdminManager.instance = undefined;
65+
manager = GleapAdminManager.getInstance();
66+
// Keep the admin UI side effects out of the origin check.
67+
manager.initAdminHelper = jest.fn(() => manager.injectFrame());
68+
manager.startPageListener = jest.fn();
69+
listeners = [];
70+
const add = window.addEventListener.bind(window);
71+
jest.spyOn(window, 'addEventListener').mockImplementation((type, fn, opts) => {
72+
if (type === 'message') listeners.push(fn);
73+
return add(type, fn, opts);
74+
});
75+
manager.start();
76+
});
77+
78+
afterEach(() => {
79+
listeners.forEach((fn) => window.removeEventListener('message', fn));
80+
jest.restoreAllMocks();
81+
});
82+
83+
it.each(['https://app.gleap.ai.evil.com', 'https://evilgleap.ai', 'https://example.com'])(
84+
'ignores a load from %s',
85+
(origin) => {
86+
load(origin);
87+
expect(manager.initAdminHelper).not.toHaveBeenCalled();
88+
expect(manager.adminOrigin).toBeNull();
89+
expect(document.querySelector('.gleap-admin-frame')).toBeNull();
90+
}
91+
);
92+
93+
it.each([
94+
['https://app.gleap.ai', 'tours', 'https://app.gleap.ai/producttourbuilder'],
95+
['https://app.gleap.io', 'tooltips', 'https://app.gleap.io/tooltipbuilder'],
96+
])('loads the builder from the opener origin %s', (origin, type, src) => {
97+
load(origin, type);
98+
expect(manager.initAdminHelper).toHaveBeenCalledTimes(1);
99+
expect(manager.adminOrigin).toBe(origin);
100+
expect(document.querySelector('.gleap-admin-frame').getAttribute('src')).toBe(src);
101+
});
102+
103+
it('defaults the builder to app.gleap.ai', () => {
104+
manager.injectFrame();
105+
expect(document.querySelector('.gleap-admin-frame').getAttribute('src')).toBe(
106+
'https://app.gleap.ai/producttourbuilder'
107+
);
108+
});
109+
});

0 commit comments

Comments
 (0)