Skip to content

Commit 0d4b2cb

Browse files
committed
fix(manager): safely restore tab titles and icons
1 parent 11d86de commit 0d4b2cb

3 files changed

Lines changed: 269 additions & 3 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
(function (root, factory) {
2+
var helper = factory();
3+
if (typeof module === 'object' && module.exports) module.exports = helper;
4+
root.evoManagerTabState = helper;
5+
}(typeof window !== 'undefined' ? window : this, function () {
6+
'use strict';
7+
8+
// This list is deliberately smaller than the Manager action registry.
9+
// Unknown actions and extra parameters must never acquire a fresh token on restore.
10+
var readActions = ['2', '3', '27', '76', '106', '107'];
11+
var coreParams = { id: '^\\d+$', tab: '^[a-zA-Z0-9_-]{1,64}$' };
12+
13+
function read(url, options) {
14+
try {
15+
var base = new URL(options.base, options.origin);
16+
var input = String(url || '');
17+
if (!input || input.length > 8192) return null;
18+
if (input.charAt(0) === '#') input = input.slice(1);
19+
var target = new URL(input, base);
20+
if (target.hash) {
21+
if (target.origin !== base.origin || target.search
22+
|| (target.pathname !== base.pathname && target.pathname !== base.pathname + 'index.php')) return null;
23+
target = new URL(target.hash.slice(1), base);
24+
}
25+
if (target.origin !== base.origin || target.username || target.password || target.hash) return null;
26+
if (target.pathname !== base.pathname && target.pathname !== base.pathname + 'index.php') return null;
27+
var seen = new Set(), valid = true;
28+
target.searchParams.forEach(function (value, key) {
29+
if (seen.has(key)) valid = false;
30+
seen.add(key);
31+
});
32+
if (!valid) return null;
33+
var action = target.searchParams.get('a');
34+
var params = coreParams;
35+
if (action === '112') {
36+
var id = target.searchParams.get('id');
37+
if (!options.modules || !Object.prototype.hasOwnProperty.call(options.modules, id)) return null;
38+
var rule = options.modules[id];
39+
if (!rule || !Array.isArray(rule.views) || rule.views.indexOf(target.searchParams.get('get') || '') === -1) return null;
40+
params = rule.params || {};
41+
} else if (readActions.indexOf(action) === -1) {
42+
return null;
43+
}
44+
target.searchParams.forEach(function (value, key) {
45+
if (key === 'a' || key === '_token' || (action === '112' && (key === 'id' || key === 'get'))) return;
46+
if (!Object.prototype.hasOwnProperty.call(params, key) || value.length > 512
47+
|| !(new RegExp(params[key])).test(value)) valid = false;
48+
});
49+
if (!valid) return null;
50+
target.searchParams.delete('_token');
51+
return target;
52+
} catch (error) {
53+
return null;
54+
}
55+
}
56+
57+
function storage(url, options) {
58+
var target = read(url, options);
59+
return target ? '?' + target.searchParams.toString() : '';
60+
}
61+
62+
function restore(url, options) {
63+
var target = read(url, options);
64+
if (!target || !options.token) return '';
65+
// All restored requests are fresh read-page loads, never retries of a failed action.
66+
target.searchParams.set('_token', options.token);
67+
return '?' + target.searchParams.toString();
68+
}
69+
70+
// Rebuild legacy stored labels instead of inserting their HTML. Only the
71+
// known Font Awesome icon classes survive; all label text is escaped.
72+
function title(value) {
73+
if (typeof value !== 'string' || value.length > 4096) return '';
74+
var icon = value.match(/<i\s+class=(["'])([a-z0-9 -]+)\1\s*>\s*<\/i>/i);
75+
var classes = icon ? icon[2].split(/\s+/).filter(function (name) {
76+
return /^(?:fa[brsldt]?|fa-[a-z0-9-]+)$/.test(name);
77+
}) : [];
78+
var text = value.replace(/<(script|style|svg)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
79+
.replace(/<[^>]*>/g, '').trim();
80+
if (!text || text === 'blank') return '';
81+
text = text.replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/gi, '&amp;')
82+
.replace(/</g, '&lt;').replace(/>/g, '&gt;')
83+
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
84+
return (classes.length ? '<i class="' + classes.join(' ') + '"></i> ' : '') + text;
85+
}
86+
87+
function restoreTitle(url, storedTitle, options, document) {
88+
var target = read(url, options);
89+
if (!target) return 'blank';
90+
// The current server-rendered menu is authoritative for module labels
91+
// (including SVG icons), also repairing already persisted "blank" tabs.
92+
if (target.searchParams.get('a') === '112') {
93+
var links = document.querySelectorAll('#mainMenu a[href]');
94+
for (var i = 0; i < links.length; i++) {
95+
try {
96+
var link = read(links[i].getAttribute('href'), options);
97+
if (link && link.searchParams.get('a') === '112'
98+
&& link.searchParams.get('id') === target.searchParams.get('id')) {
99+
return links[i].innerHTML;
100+
}
101+
} catch (error) { }
102+
}
103+
}
104+
return title(storedTitle) || 'blank';
105+
}
106+
107+
return { storage: storage, restore: restore, title: title, restoreTitle: restoreTitle };
108+
}));
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
const vm = require('node:vm');
6+
const state = require('../manager-tab-state.js');
7+
const options = {
8+
base: '/manager/', origin: 'https://example.test', token: 'new-session-token',
9+
modules: { commerce: { views: ['', 'dashboard', 'orders', 'order'], params: { i: '^\\d+$', page: '^\\d+$' } } }
10+
};
11+
12+
test('the top-level frameset supplies token, account scope and rules before the restoration script', () => {
13+
const frame = fs.readFileSync(path.join(__dirname, '../../../views/frame/1.blade.php'), 'utf8');
14+
assert.match(frame, /name="csrf-token" content="\{\{ csrf_token\(\) \}\}"/);
15+
assert.match(frame, /tab_restore_user:.*mgrInternalKey/);
16+
assert.match(frame, /tab_restore_modules:.*cms.manager_tab_restore.modules/);
17+
assert.ok(frame.indexOf('manager-tab-state.js') < frame.indexOf("'js/evo.js'"));
18+
assert.match(frame, /@revision\(MGR_DIR .*js\/evo.js/);
19+
});
20+
21+
test('persists a read route without the old token; restores with the current session token', () => {
22+
const stored = state.storage('?a=112&id=commerce&get=orders&_token=old', options);
23+
assert.equal(stored, '?a=112&id=commerce&get=orders');
24+
assert.equal(state.restore(stored, options), stored + '&_token=new-session-token');
25+
assert.equal(state.restore(stored, { ...options, token: 'another-login' }), stored + '&_token=another-login');
26+
assert.equal(state.restore(stored, { ...options, token: '' }), '');
27+
});
28+
29+
for (const url of [
30+
'#?a=112&id=commerce&get=orders&_token=old',
31+
'https://example.test/manager/#?a=112&id=commerce&get=orders&_token=old',
32+
'https://example.test/manager/index.php#?a=112&id=commerce&get=orders&_token=old',
33+
'index.php?a=112&id=commerce&get=orders&_token=old',
34+
]) test('validates startup URL ' + url, () => {
35+
assert.equal(state.restore(url, options), '?a=112&id=commerce&get=orders&_token=new-session-token');
36+
});
37+
38+
for (const url of [
39+
'?a=6&id=3', '#?a=112&id=commerce&get=orderDelete&i=3',
40+
'?a=112&id=commerce&get=orderSave', '?a=112&id=unknown&get=orders',
41+
'?a=112&id=commerce&get=orders&operation=delete', '?a=112&id=commerce&get=order&i=1&back=evil',
42+
'?a=112&id=commerce&get=order&i[]=1', '?a=112&id=commerce&get=order&i=1%0Aevil',
43+
'?a=112&id=commerce&get=order&i=1%0A', '?a=2&a=6', '?a=2&%61=6',
44+
'?a=2&_token=one&_token=two', '?a=2&filemanager=1', '?a=2&r=1',
45+
'?a=2&_method=POST', 'https://evil.test/manager/?a=2',
46+
'https://user:pass@example.test/manager/?a=2', '/other/?a=2',
47+
'javascript:alert(1)', '?a=112&id=__proto__&get=orders', '?a=2#?a=6',
48+
]) test('never re-signs unsafe or ambiguous URL ' + url, () => {
49+
assert.equal(state.storage(url, options), '');
50+
assert.equal(state.restore(url, options), '');
51+
});
52+
53+
// Execute the real theme methods without loading an authenticated manager or a browser.
54+
for (const [theme, name] of [['default', 'evo']]) {
55+
test(theme + ' uses the same safe restoration path and does not restore stored title HTML', () => {
56+
const source = fs.readFileSync(path.join(__dirname, '../../style', theme, 'js', name + '.js'), 'utf8');
57+
const manager = { config: {}, extended(methods) { Object.assign(this, methods); } };
58+
const doc = { baseURI: options.origin + options.base, querySelector: () => ({ getAttribute: () => options.token }), querySelectorAll: () => [], addEventListener() {} };
59+
const win = { tree: {}, location: { origin: options.origin }, evoManagerTabState: state, addEventListener() {} };
60+
const sandbox = { window: win, document: doc, navigator: { userAgent: '', appVersion: '' }, Element: function () {}, [name]: manager, URL, console };
61+
vm.createContext(sandbox);
62+
vm.runInContext(source, sandbox);
63+
manager.config = { global_tabs: true, tab_restore_modules: options.modules };
64+
manager.EVO_MANAGER_URL = manager.MODX_MANAGER_URL = options.base;
65+
assert.equal(manager.tabUrlForRestore('?a=6'), '');
66+
assert.equal(manager.tabUrlForRestore('#?a=112&id=commerce&get=orders'), '?a=112&id=commerce&get=orders&_token=new-session-token');
67+
const opened = [];
68+
manager.tabs = tab => opened.push(tab);
69+
win.localStorage = sandbox.localStorage = { getItem: () => JSON.stringify({ tabs: [
70+
{ url: '?a=6', title: 'unsafe' },
71+
{ url: '?a=112&id=commerce&get=orders&_token=old', title: '<img onerror=evil()>' }
72+
] }) };
73+
assert.equal(manager.tabsRestore(), true);
74+
assert.equal(opened.length, 1);
75+
assert.equal(opened[0].title, 'blank');
76+
assert.equal(opened[0].url.includes('_token=old'), false);
77+
win.localStorage.getItem = () => JSON.stringify({ tabs: [
78+
{ url: '?a=112&id=commerce&get=orders', title: '<i class="fa fa-store"></i> Комерція' }
79+
] });
80+
opened.length = 0;
81+
manager.tabsRestore();
82+
assert.equal(opened[0].title, '<i class="fa fa-store"></i> Комерція');
83+
84+
// A server-rendered menu repairs a title previously persisted as blank.
85+
const menuTitle = '<i class="fa fa-store"></i> Комерція';
86+
doc.querySelectorAll = () => [{ getAttribute: () => 'index.php?a=112&id=commerce', innerHTML: menuTitle }];
87+
win.localStorage.getItem = () => JSON.stringify({ tabs: [
88+
{ url: '?a=112&id=commerce&get=orders', title: 'blank' }
89+
], active: '?a=112&id=commerce&get=orders' });
90+
opened.length = 0;
91+
manager.tabsRestore();
92+
assert.equal(opened.length, 2);
93+
assert.ok(opened.every(tab => tab.title === menuTitle));
94+
assert.match(source, /startupUrl = (evo|modx)\.tabUrlForRestore\(w.location.href\)/);
95+
assert.match(source, /EVO_Tabs:.*tab_restore_user/);
96+
97+
for (const method of ['get', 'post']) {
98+
const headers = {};
99+
manager.XHR = () => ({ open() {}, setRequestHeader: (key, value) => { headers[key] = value; }, send() {} });
100+
manager[method]('?a=67');
101+
assert.equal(headers['X-CSRF-TOKEN'], options.token);
102+
delete headers['X-CSRF-TOKEN'];
103+
manager[method]('https://other.test/');
104+
assert.equal(headers['X-CSRF-TOKEN'], undefined);
105+
}
106+
107+
// Exercise actual startup with fresh server state and a stale browser hash.
108+
for (const component of ['mainmenu', 'resizer', 'moduleViewport', 'search']) manager[component] = { init() {} };
109+
manager.config.tab_restore_user = '42';
110+
win.localStorage = sandbox.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
111+
win.history = { replaceState() {} };
112+
manager.EVO_MANAGER_URL = manager.MODX_MANAGER_URL = options.origin + options.base;
113+
opened.length = 0;
114+
win.location.href = options.origin + options.base + '#?a=112&id=commerce&get=orders&_token=old';
115+
manager.init();
116+
assert.equal(opened.at(-1).url, '?a=112&id=commerce&get=orders&_token=new-session-token');
117+
assert.equal(opened.at(-1).title, menuTitle);
118+
assert.ok(manager.tabsStorageKey.endsWith(':42'));
119+
opened.length = 0;
120+
win.location.href = options.origin + options.base + '#?a=112&id=commerce&get=orderDelete&i=1';
121+
manager.init();
122+
assert.equal(opened.length, 1);
123+
assert.equal(opened[0].url, '?a=2');
124+
});
125+
}
126+
127+
test('stored labels retain text and safe icons without restoring executable HTML', () => {
128+
assert.equal(state.title('<i class="fa fa-store"></i> Комерція'), '<i class="fa fa-store"></i> Комерція');
129+
assert.equal(state.title('Назва <small>(12)</small>'), 'Назва (12)');
130+
assert.equal(state.title('A &amp; B "quoted"'), 'A &amp; B &quot;quoted&quot;');
131+
assert.equal(state.title('<img src=x onerror=alert(1)>Комерція'), 'Комерція');
132+
assert.equal(state.title('<script>alert(1)</script>Комерція'), 'Комерція');
133+
assert.equal(state.title('<svg onload=alert(1)><script>alert(1)</script></svg>Комерція'), 'Комерція');
134+
assert.equal(state.title('<i class="fa fa-store" onclick="alert(1)"></i>Комерція'), 'Комерція');
135+
assert.equal(state.title('<i class="fa fa-store hidden"></i>Комерція'), '<i class="fa fa-store"></i> Комерція');
136+
assert.equal(state.title('" onmouseover="alert(1)'), '&quot; onmouseover=&quot;alert(1)');
137+
assert.equal(state.title('blank'), '');
138+
assert.equal(state.title({ html: 'Комерція' }), '');
139+
const label = '<i class="fa fa-store"></i> A &amp; B';
140+
assert.equal(state.title(state.title(label)), label);
141+
});
142+
143+
test('module titles use only matching same-origin audited menu links', () => {
144+
const url = '?a=112&id=commerce&get=orders';
145+
const menu = (href, innerHTML) => ({ getAttribute: () => href, innerHTML });
146+
const trustedTitle = '<svg viewBox="0 0 24 24"><path d="M0 0"></path></svg> Комерція';
147+
const doc = { querySelectorAll: () => [
148+
menu('https://evil.test/manager/?a=112&id=commerce', 'Wrong origin'),
149+
menu('index.php?a=112&id=other', 'Wrong module'),
150+
menu('index.php?a=112&id=commerce&get=orderDelete', 'Mutation'),
151+
menu('index.php?a=112&id=commerce', trustedTitle)
152+
] };
153+
assert.equal(state.restoreTitle(url, 'blank', options, doc), trustedTitle);
154+
assert.equal(state.restoreTitle('?a=6', 'unsafe', options, doc), 'blank');
155+
});

‎manager/media/style/default/js/evo.js‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
tabUrlForRestore: function (url) {
2222
return w.evoManagerTabState ? w.evoManagerTabState.restore(url, evo.tabsRestoreOptions()) : '';
2323
},
24+
tabsTitleForRestore: function (url, title) {
25+
return w.evoManagerTabState ? w.evoManagerTabState.restoreTitle(url, title, evo.tabsRestoreOptions(), d) : 'blank';
26+
},
2427
tabsRestoreOptions: function () {
2528
return { base: evo.EVO_MANAGER_URL, origin: w.location.origin, token: evo.tabsCsrfToken(), modules: evo.config.tab_restore_modules };
2629
},
@@ -117,7 +120,7 @@
117120
if (tab && tab.url) {
118121
var tabUrl = evo.tabUrlForRestore(tab.url);
119122
if (tabUrl) {
120-
evo.tabs({ url: tabUrl, title: 'blank', reload: 0, restoring: true });
123+
evo.tabs({ url: tabUrl, title: evo.tabsTitleForRestore(tabUrl, tab.title), reload: 0, restoring: true });
121124
restored = true;
122125
}
123126
}
@@ -126,7 +129,7 @@
126129
if (data.active) {
127130
var activeUrl = evo.tabUrlForRestore(data.active);
128131
if (activeUrl) {
129-
evo.tabs({ url: activeUrl, title: 'blank', restoring: true, activate: true });
132+
evo.tabs({ url: activeUrl, title: evo.tabsTitleForRestore(activeUrl), restoring: true, activate: true });
130133
restored = true;
131134
}
132135
}
@@ -180,7 +183,7 @@
180183
this.tabs({ url: '?a=2', reload: 0 });
181184
}
182185
if (openOnLoad) {
183-
evo.tabs({ url: startupUrl, title: 'blank' });
186+
evo.tabs({ url: startupUrl, title: evo.tabsTitleForRestore(startupUrl) });
184187
}
185188
} else if (openOnLoad) {
186189
if (w.main) {

0 commit comments

Comments
 (0)