|
| 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 & B "quoted"'), 'A & B "quoted"'); |
| 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)'), '" onmouseover="alert(1)'); |
| 137 | + assert.equal(state.title('blank'), ''); |
| 138 | + assert.equal(state.title({ html: 'Комерція' }), ''); |
| 139 | + const label = '<i class="fa fa-store"></i> A & 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 | +}); |
0 commit comments