From 49491e41a46ff21ba9877c5a65a9211ed51ff62f Mon Sep 17 00:00:00 2001 From: "Fidelin, Eugene" Date: Mon, 24 Aug 2026 16:46:54 +0200 Subject: [PATCH 1/3] Prevent activeMatch from being forwarded to menu DOM elements ## Summary Fixes the following React warning produced when rendering menus with `activeMatch`: ```text Warning: React does not recognize the `activeMatch` prop on a DOM element. If you accidentally passed it from a parent component, remove it from the DOM element. ``` ## Root cause `MetaMenu` uses the Muse-specific `activeMatch` property to calculate the active menu keys. After evaluating it, the original menu-item objects are passed to Ant Design unchanged. Ant Design/rc-menu does not recognize `activeMatch` as a menu property and forwards it to the underlying `
  • `, which triggers the React warning. ## Fix Before passing items to Ant Design, `MetaMenu` now recursively clones the menu tree and removes `activeMatch`. The original internal items are retained for route matching, so existing active-menu behavior is unchanged. Recursive sanitization covers both top-level submenu items and nested children. ## Testing - Added regression coverage for parent and nested menu items containing `activeMatch`. - Verified that unsanitized items produce the React warning while sanitized items do not. - Confirmed the `muse-lib-antd` production build succeeds. --- .../src/features/common/MetaMenu.jsx | 13 ++++++- .../tests/features/common/MetaMenu.test.js | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/ui-plugins/muse-lib-antd/src/features/common/MetaMenu.jsx b/ui-plugins/muse-lib-antd/src/features/common/MetaMenu.jsx index b9152ffae..90d85ca66 100644 --- a/ui-plugins/muse-lib-antd/src/features/common/MetaMenu.jsx +++ b/ui-plugins/muse-lib-antd/src/features/common/MetaMenu.jsx @@ -6,6 +6,17 @@ import { Link, useLocation } from 'react-router-dom'; import plugin from 'js-plugin'; import getIconNode from './getIconNode'; +const getAntdMenuItems = items => + items.map(item => { + const antdItem = _.omit(item, 'activeMatch'); + + if (item.children) { + antdItem.children = getAntdMenuItems(item.children); + } + + return antdItem; + }); + /* Meta driven menu based on Antd's Menu component. Supported features: @@ -136,7 +147,7 @@ export default function MetaMenu({ meta = {}, onClick, baseExtPoint, autoSort = ...meta.menuProps, className: menuClassnames.join(' '), theme: meta.theme || 'light', - items: newItems, + items: getAntdMenuItems(newItems), }; if (menuMode === 'inline' && !meta.hasOwnProperty('collapsed')) diff --git a/ui-plugins/muse-lib-antd/tests/features/common/MetaMenu.test.js b/ui-plugins/muse-lib-antd/tests/features/common/MetaMenu.test.js index 4d2b01cb0..2becb0f3f 100644 --- a/ui-plugins/muse-lib-antd/tests/features/common/MetaMenu.test.js +++ b/ui-plugins/muse-lib-antd/tests/features/common/MetaMenu.test.js @@ -9,6 +9,40 @@ import history from '../../../src/common/history'; import { MenuUnfoldOutlined } from '@ant-design/icons'; describe('common/MetaMenu', () => { + it('does not forward activeMatch to Ant Design menu item DOM elements', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const meta = { + autoActive: true, + mode: 'inline', + items: [ + { + key: 'seller', + label: 'Seller', + activeMatch: () => true, + children: [ + { + key: 'event', + label: 'Event', + activeMatch: () => true, + }, + ], + }, + ], + }; + + try { + render( + + + , + ); + + expect(consoleError.mock.calls.flat().join(' ')).not.toContain('activeMatch'); + } finally { + consoleError.mockRestore(); + } + }); + it('renders Sider MetaMenu with ', async () => { const closeDrawer = jest.fn(); From 699cd6b1f43cec424460211e1307ab0795bbfdef Mon Sep 17 00:00:00 2001 From: Nate Wang Date: Mon, 7 Sep 2026 13:31:43 +0800 Subject: [PATCH 2/3] add new plugin template --- plugin-templates/msp2606/.gitignore | 11 ++++++ plugin-templates/msp2606/.npmrc | 1 + plugin-templates/msp2606/README.md | 12 +++++++ plugin-templates/msp2606/package.json | 44 +++++++++++++++++++++++ plugin-templates/msp2606/src/ext/index.js | 1 + plugin-templates/msp2606/src/index.js | 11 ++++++ plugin-templates/msp2606/src/reducer.js | 3 ++ plugin-templates/msp2606/src/route.js | 5 +++ plugin-templates/msp2606/vite.config.js | 7 ++++ 9 files changed, 95 insertions(+) create mode 100644 plugin-templates/msp2606/.gitignore create mode 100644 plugin-templates/msp2606/.npmrc create mode 100644 plugin-templates/msp2606/README.md create mode 100644 plugin-templates/msp2606/package.json create mode 100644 plugin-templates/msp2606/src/ext/index.js create mode 100644 plugin-templates/msp2606/src/index.js create mode 100644 plugin-templates/msp2606/src/reducer.js create mode 100644 plugin-templates/msp2606/src/route.js create mode 100644 plugin-templates/msp2606/vite.config.js diff --git a/plugin-templates/msp2606/.gitignore b/plugin-templates/msp2606/.gitignore new file mode 100644 index 000000000..005a44cd9 --- /dev/null +++ b/plugin-templates/msp2606/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +build/ +coverage/ +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/plugin-templates/msp2606/.npmrc b/plugin-templates/msp2606/.npmrc new file mode 100644 index 000000000..bf2e7648b --- /dev/null +++ b/plugin-templates/msp2606/.npmrc @@ -0,0 +1 @@ +shamefully-hoist=true diff --git a/plugin-templates/msp2606/README.md b/plugin-templates/msp2606/README.md new file mode 100644 index 000000000..d13bc0c31 --- /dev/null +++ b/plugin-templates/msp2606/README.md @@ -0,0 +1,12 @@ +# + +Muse plugin created with the `msp2606` SDK preset. + +## Development + +```bash +pnpm install +pnpm start +``` + +Update `muse.devConfig` in `package.json` to select the Muse application and environment used for local development. diff --git a/plugin-templates/msp2606/package.json b/plugin-templates/msp2606/package.json new file mode 100644 index 000000000..1311e311f --- /dev/null +++ b/plugin-templates/msp2606/package.json @@ -0,0 +1,44 @@ +{ + "name": "", + "version": "1.0.0", + "private": true, + "type": "module", + "muse": { + "msp": "msp2606", + "type": "normal", + "devConfig": { + "app": "myapp", + "env": "staging" + } + }, + "scripts": { + "start": "vite", + "build": "vite build", + "build:dev": "cross-env NODE_ENV=development vite build --mode development", + "build:test": "cross-env MUSE_TEST_BUILD=true NODE_ENV=production vite build --mode e2e-test" + }, + "dependencies": { + "js-plugin": "1.1.0" + }, + "devDependencies": { + "@ebay/muse-core": "^2.0.0", + "@ebay/muse-lib-antd": "^2.0.0", + "@ebay/muse-lib-react": "^2.0.0", + "@ebay/muse-vite-plugin": "^2.0.0", + "@vitejs/plugin-react": "^6.0.1", + "cross-env": "^10.1.0", + "vite": "^8.0.11" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/plugin-templates/msp2606/src/ext/index.js b/plugin-templates/msp2606/src/ext/index.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/plugin-templates/msp2606/src/ext/index.js @@ -0,0 +1 @@ +export {}; diff --git a/plugin-templates/msp2606/src/index.js b/plugin-templates/msp2606/src/index.js new file mode 100644 index 000000000..a352d6fda --- /dev/null +++ b/plugin-templates/msp2606/src/index.js @@ -0,0 +1,11 @@ +import plugin from 'js-plugin'; +import * as ext from './ext'; +import reducer from './reducer'; +import route from './route'; + +plugin.register({ + ...ext, + name: '', + route, + reducer, +}); diff --git a/plugin-templates/msp2606/src/reducer.js b/plugin-templates/msp2606/src/reducer.js new file mode 100644 index 000000000..41969eb0d --- /dev/null +++ b/plugin-templates/msp2606/src/reducer.js @@ -0,0 +1,3 @@ +const reducer = (state = {}) => state; + +export default reducer; diff --git a/plugin-templates/msp2606/src/route.js b/plugin-templates/msp2606/src/route.js new file mode 100644 index 000000000..b0bf0e277 --- /dev/null +++ b/plugin-templates/msp2606/src/route.js @@ -0,0 +1,5 @@ +const route = { + childRoutes: [], +}; + +export default route; diff --git a/plugin-templates/msp2606/vite.config.js b/plugin-templates/msp2606/vite.config.js new file mode 100644 index 000000000..fe3cf8a7c --- /dev/null +++ b/plugin-templates/msp2606/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import museVitePlugin from '@ebay/muse-vite-plugin'; + +export default defineConfig(() => ({ + plugins: [react(), museVitePlugin()], +})); From ec7c48a6f235d7f713752331851f66543c9ed74f Mon Sep 17 00:00:00 2001 From: Lin Gao Date: Mon, 7 Sep 2026 17:01:32 +0800 Subject: [PATCH 3/3] Prevent DOM XSS in muse-boot-default error UI and restrict forcePlugins. Render untrusted boot errors as text, validate plugin name/type/version, and only apply forcePlugins in local/dev or Muse e2e. Co-authored-by: Cursor --- ui-plugins/muse-boot-default/MUSE_README.md | 6 +- ui-plugins/muse-boot-default/package.json | 2 +- ui-plugins/muse-boot-default/src/boot.js | 50 ++----- ui-plugins/muse-boot-default/src/boot.test.js | 82 ++++++++++++ ui-plugins/muse-boot-default/src/error.js | 61 ++++++--- .../muse-boot-default/src/error.test.js | 14 ++ .../muse-boot-default/src/forcePlugins.js | 96 ++++++++++++++ .../src/forcePlugins.test.js | 122 ++++++++++++++++++ ui-plugins/muse-boot-default/src/loading.js | 2 +- 9 files changed, 372 insertions(+), 63 deletions(-) create mode 100644 ui-plugins/muse-boot-default/src/forcePlugins.js create mode 100644 ui-plugins/muse-boot-default/src/forcePlugins.test.js diff --git a/ui-plugins/muse-boot-default/MUSE_README.md b/ui-plugins/muse-boot-default/MUSE_README.md index c7c5afe10..a72d3165b 100644 --- a/ui-plugins/muse-boot-default/MUSE_README.md +++ b/ui-plugins/muse-boot-default/MUSE_README.md @@ -376,10 +376,10 @@ const MyComponent = () => { ### Example 5: Force Loading Specific Plugin Versions -For debugging or testing, use the `forcePlugins` query parameter: +For debugging, local development, or Muse e2e, use the `forcePlugins` query parameter. It is applied when `isDev`, `isLocal`, or `isE2eTest` is true. Plugin names, types (`boot` / `init` / `lib` / `normal`), and versions must be valid; other values are ignored. ``` -https://myapp.com?forcePlugins=@ebay/my-plugin@1.2.3;other-plugin@2.0.0 +https://myapp.com?forcePlugins=@ebay/my-plugin@1.2.3;other-plugin!normal@2.0.0 ``` This overrides the deployed plugin versions with specific versions. @@ -394,7 +394,7 @@ This overrides the deployed plugin versions with specific versions. - Handles plugin loading order: boot → init → lib → normal - Init plugins can use `initEntries` or `waitFor` to perform async initialization - Lib plugins with `isAppEntry: true` register app entry functions -- The `forcePlugins` query parameter is useful for debugging specific plugin versions +- The `forcePlugins` query parameter is useful for debugging specific plugin versions in local/dev or Muse e2e - Service worker registration is automatic but can be customized - All plugin loading happens in parallel for performance (within each type group) - The loading UI provides user feedback during the bootstrap process diff --git a/ui-plugins/muse-boot-default/package.json b/ui-plugins/muse-boot-default/package.json index a070611f5..c43e0f97e 100644 --- a/ui-plugins/muse-boot-default/package.json +++ b/ui-plugins/muse-boot-default/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/ebay/Muse", "directory": "ui-plugins/muse-boot-default" }, - "version": "2.0.2", + "version": "2.0.3", "main": "index.js", "license": "MIT", "type": "module", diff --git a/ui-plugins/muse-boot-default/src/boot.js b/ui-plugins/muse-boot-default/src/boot.js index 4077f69e6..1751a3e4b 100644 --- a/ui-plugins/muse-boot-default/src/boot.js +++ b/ui-plugins/muse-boot-default/src/boot.js @@ -3,6 +3,7 @@ import loading from './loading'; import error from './error'; import registerSw from './registerSw'; import { loadInParallel, loadInSerial, getPluginId } from './utils'; +import { applyForcePlugins, isForcePluginsAllowed } from './forcePlugins'; import msgEngine from './msgEngine'; import './urlListener'; import './style.css'; @@ -105,51 +106,16 @@ async function start() { ); } - /* Handle forcePlugins query parameter */ + /* Handle forcePlugins query parameter (local/dev or Muse e2e) */ const searchParams = new URLSearchParams(window.location.search); const forcePluginStr = searchParams.get('forcePlugins'); if (forcePluginStr) { - const forcePluginById = forcePluginStr - .split(';') - .filter(Boolean) - .reduce((p, c) => { - const separator = '@'; - const limit = 2; - let prefix = ''; - if (c.startsWith('@') && c[0] === separator) { - // Starts with @, means it's a scoped plugin - c = c.substring(1); - prefix = '@'; - } - const arr = c.split(separator, limit); - if (arr.length === limit) { - const [name, type] = arr[0].split('!'); - p[`${prefix}${name}`] = { - version: arr[1], - type: type, - }; - } - return p; - }, {}); - // Update or remove plugins from the list based on forcePlugins - plugins = plugins - .map((p) => { - if (!forcePluginById[p.name]) return p; - const newPlugin = { ...p, version: forcePluginById[p.name].version }; - delete forcePluginById[p.name]; - return newPlugin; - }) - .filter((p) => p.version !== 'null'); - - // Need to get the type of plugin from muse registry directly. - for (const p in forcePluginById) { - if (forcePluginById[p].version !== 'null') { - plugins.push({ - name: p, - type: forcePluginById[p].type, - version: forcePluginById[p].version, - }); - } + if (isForcePluginsAllowed(mg)) { + plugins = applyForcePlugins(plugins, forcePluginStr); + } else { + console.warn( + '[muse-boot] forcePlugins is ignored outside local/dev and Muse e2e.', + ); } } diff --git a/ui-plugins/muse-boot-default/src/boot.test.js b/ui-plugins/muse-boot-default/src/boot.test.js index 56dbfd4d3..e2e378a13 100644 --- a/ui-plugins/muse-boot-default/src/boot.test.js +++ b/ui-plugins/muse-boot-default/src/boot.test.js @@ -46,6 +46,7 @@ vi.mock('./style.css', () => ({})); import loading from './loading.js'; import error from './error.js'; import msgEngine from './msgEngine.js'; +import { loadInParallel } from './utils.js'; import { bootstrap } from './boot.js'; function makeMuseGlobal(overrides = {}) { @@ -261,6 +262,87 @@ describe('bootstrap', () => { expect(loader).toHaveBeenCalledTimes(1); }); + + it('ignores forcePlugins in deployed environments', async () => { + const prevLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { search: '?forcePlugins=poc!init@1.0.0' }, + }); + window.MUSE_GLOBAL.appEntries = [{ name: 'main', func: vi.fn().mockResolvedValue(undefined) }]; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(document.head, 'appendChild').mockImplementation((el) => { + if (el.tagName === 'SCRIPT' && el.textContent?.includes('__onMusePluginsLoaded')) { + window.MUSE_GLOBAL.__onMusePluginsLoaded?.(); + } + }); + + try { + await runBootstrap(); + const initPlugins = loadInParallel.mock.calls[0]?.[0] || []; + expect(initPlugins.some((p) => p.name === 'poc')).toBe(false); + } finally { + Object.defineProperty(window, 'location', { configurable: true, value: prevLocation }); + } + }); + + it('applies valid forcePlugins in dev', async () => { + const prevLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { search: '?forcePlugins=extra!init@1.0.1' }, + }); + window.MUSE_GLOBAL = makeMuseGlobal({ + isDev: true, + appEntries: [{ name: 'main', func: vi.fn().mockResolvedValue(undefined) }], + }); + vi.spyOn(document.head, 'appendChild').mockImplementation((el) => { + if (el.tagName === 'SCRIPT' && el.textContent?.includes('__onMusePluginsLoaded')) { + window.MUSE_GLOBAL.__onMusePluginsLoaded?.(); + } + }); + + try { + await runBootstrap(); + const initPlugins = loadInParallel.mock.calls[0][0]; + expect(initPlugins).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'extra', type: 'init', version: '1.0.1' }), + ]), + ); + } finally { + Object.defineProperty(window, 'location', { configurable: true, value: prevLocation }); + } + }); + + it('applies valid forcePlugins when isE2eTest is true', async () => { + const prevLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { search: '?forcePlugins=extra!init@1.0.1' }, + }); + window.MUSE_GLOBAL = makeMuseGlobal({ + isE2eTest: true, + appEntries: [{ name: 'main', func: vi.fn().mockResolvedValue(undefined) }], + }); + vi.spyOn(document.head, 'appendChild').mockImplementation((el) => { + if (el.tagName === 'SCRIPT' && el.textContent?.includes('__onMusePluginsLoaded')) { + window.MUSE_GLOBAL.__onMusePluginsLoaded?.(); + } + }); + + try { + await runBootstrap(); + const initPlugins = loadInParallel.mock.calls[0][0]; + expect(initPlugins).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'extra', type: 'init', version: '1.0.1' }), + ]), + ); + } finally { + Object.defineProperty(window, 'location', { configurable: true, value: prevLocation }); + } + }); }); describe('bootstrap - appConfig.entry selection', () => { diff --git a/ui-plugins/muse-boot-default/src/error.js b/ui-plugins/muse-boot-default/src/error.js index 51b80dab0..9a1894540 100644 --- a/ui-plugins/muse-boot-default/src/error.js +++ b/ui-plugins/muse-boot-default/src/error.js @@ -1,8 +1,17 @@ +function isSafeHref(href) { + if (!href || href === '#') return true; + try { + const url = new URL(href, window.location.origin); + return url.protocol === 'https:' || url.protocol === 'http:'; + } catch { + return false; + } +} + const error = { errors: [], init() { const errorDiv = document.createElement('div'); - errorDiv.innerHTML = ``; errorDiv.id = 'muse-error-node'; document.body.appendChild(errorDiv); this.mountNode = errorDiv; @@ -14,22 +23,42 @@ const error = { }, update() { if (!this.mountNode) this.init(); + this.mountNode.replaceChildren(); + + const inner = document.createElement('div'); + inner.className = 'muse-error-node-inner'; + + const heading = document.createElement('h4'); + heading.textContent = 'Failed to load:'; + inner.appendChild(heading); + + if (this.errors.length === 1) { + const div = document.createElement('div'); + div.textContent = String(this.errors[0] ?? ''); + inner.appendChild(div); + } else { + const ul = document.createElement('ul'); + this.errors.forEach((err) => { + const li = document.createElement('li'); + li.textContent = String(err ?? ''); + ul.appendChild(li); + }); + inner.appendChild(ul); + } + + const note = document.createElement('p'); + note.appendChild( + document.createTextNode('* Unexpected error happened, please refresh to retry or '), + ); + const supportLink = window.MUSE_GLOBAL?.appConfig?.supportLink || '#'; + const contact = document.createElement('a'); + contact.textContent = 'contact support'; + contact.href = isSafeHref(supportLink) ? supportLink : '#'; + note.appendChild(contact); + note.appendChild(document.createTextNode('.')); + inner.appendChild(note); - const content = - this.errors.length === 1 - ? `
    ${this.errors[0]}
    ` - : `
      - ${this.errors.map((err) => '
    • ' + err + '
    • ').join('')} -
    `; - this.mountNode.innerHTML = ` -
    -

    Failed to load:

    - ${content} -

    * Unexpected error happened, please refresh to retry or contact support.

    -
    - `; + this.mountNode.appendChild(inner); }, }; diff --git a/ui-plugins/muse-boot-default/src/error.test.js b/ui-plugins/muse-boot-default/src/error.test.js index 464d44298..61a2548b3 100644 --- a/ui-plugins/muse-boot-default/src/error.test.js +++ b/ui-plugins/muse-boot-default/src/error.test.js @@ -78,5 +78,19 @@ describe('error', () => { error.update(); expect(error.mountNode.innerHTML).toContain('href="#"'); }); + + it('renders untrusted error text without executing HTML', () => { + error.showMessage(''); + expect(error.mountNode.querySelector('img')).toBeNull(); + expect(error.mountNode.textContent).toContain(''); + }); + + it('rejects javascript: support links', () => { + setupMuseGlobal({ appConfig: { supportLink: 'javascript:alert(1)' } }); + error.errors = ['oops']; + error.init(); + error.update(); + expect(error.mountNode.querySelector('a').getAttribute('href')).toBe('#'); + }); }); }); diff --git a/ui-plugins/muse-boot-default/src/forcePlugins.js b/ui-plugins/muse-boot-default/src/forcePlugins.js new file mode 100644 index 000000000..82cceee6c --- /dev/null +++ b/ui-plugins/muse-boot-default/src/forcePlugins.js @@ -0,0 +1,96 @@ +const PLUGIN_TYPES = new Set(['boot', 'init', 'lib', 'normal']); +const UNNAMED_PLUGIN = /^[a-zA-Z0-9._-]+$/; +const SCOPED_PLUGIN = /^@[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export function isValidPluginName(name) { + return typeof name === 'string' && (UNNAMED_PLUGIN.test(name) || SCOPED_PLUGIN.test(name)); +} + +export function isValidPluginType(type) { + return PLUGIN_TYPES.has(type); +} + +export function isValidPluginVersion(version) { + return version === 'null' || (typeof version === 'string' && SEMVER.test(version)); +} + +/** + * forcePlugins is for local/dev and Muse e2e. Staging/production user traffic + * leaves it off so URL query params cannot swap plugins. + */ +export function isForcePluginsAllowed({ isDev, isLocal, isE2eTest } = {}) { + return Boolean(isDev || isLocal || isE2eTest); +} + +export function parseForcePlugins(forcePluginStr) { + if (!forcePluginStr) return {}; + return forcePluginStr + .split(';') + .filter(Boolean) + .reduce((acc, entry) => { + const separator = '@'; + let prefix = ''; + let value = entry; + if (value.startsWith('@')) { + value = value.substring(1); + prefix = '@'; + } + const arr = value.split(separator, 2); + if (arr.length !== 2) return acc; + const [name, type] = arr[0].split('!'); + const pluginName = `${prefix}${name}`; + acc[pluginName] = { + version: arr[1], + type, + }; + return acc; + }, {}); +} + +function warnIgnored(reason) { + console.warn(`[muse-boot] Ignoring forcePlugins entry: ${reason}`); +} + +export function applyForcePlugins(plugins, forcePluginStr) { + const forcePluginById = parseForcePlugins(forcePluginStr); + const remaining = { ...forcePluginById }; + + const next = plugins + .map((plugin) => { + const forced = remaining[plugin.name]; + if (!forced) return plugin; + delete remaining[plugin.name]; + if (!isValidPluginVersion(forced.version)) { + warnIgnored(`invalid version for ${plugin.name}`); + return plugin; + } + if (forced.type && !isValidPluginType(forced.type)) { + warnIgnored(`invalid type for ${plugin.name}`); + return plugin; + } + return { ...plugin, version: forced.version }; + }) + .filter((plugin) => plugin.version !== 'null'); + + for (const name of Object.keys(remaining)) { + const forced = remaining[name]; + if (forced.version === 'null') continue; + if ( + !isValidPluginName(name) || + !isValidPluginType(forced.type) || + !isValidPluginVersion(forced.version) + ) { + warnIgnored(`invalid name, type, or version for ${name}`); + continue; + } + next.push({ + name, + type: forced.type, + version: forced.version, + }); + } + + return next; +} diff --git a/ui-plugins/muse-boot-default/src/forcePlugins.test.js b/ui-plugins/muse-boot-default/src/forcePlugins.test.js new file mode 100644 index 000000000..2bb3ace6a --- /dev/null +++ b/ui-plugins/muse-boot-default/src/forcePlugins.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + isValidPluginName, + isValidPluginType, + isValidPluginVersion, + isForcePluginsAllowed, + parseForcePlugins, + applyForcePlugins, +} from './forcePlugins.js'; + +describe('isValidPluginName', () => { + it('accepts unscoped and scoped names', () => { + expect(isValidPluginName('my-plugin')).toBe(true); + expect(isValidPluginName('@ebay/muse-lib-react')).toBe(true); + }); + + it('rejects HTML and path junk', () => { + expect(isValidPluginName('poc { + it('accepts known Muse plugin types', () => { + expect(isValidPluginType('boot')).toBe(true); + expect(isValidPluginType('init')).toBe(true); + expect(isValidPluginType('lib')).toBe(true); + expect(isValidPluginType('normal')).toBe(true); + }); + + it('rejects anything else', () => { + expect(isValidPluginType('Init')).toBe(false); + expect(isValidPluginType('')).toBe(false); + expect(isValidPluginType(undefined)).toBe(false); + }); +}); + +describe('isValidPluginVersion', () => { + it('accepts semver and the null sentinel', () => { + expect(isValidPluginVersion('1.0.29')).toBe(true); + expect(isValidPluginVersion('1.2.3-beta.1')).toBe(true); + expect(isValidPluginVersion('null')).toBe(true); + }); + + it('rejects HTML payloads and incomplete versions', () => { + expect(isValidPluginVersion('')).toBe(false); + expect(isValidPluginVersion('1.0')).toBe(false); + expect(isValidPluginVersion('v1.0.0')).toBe(false); + }); +}); + +describe('isForcePluginsAllowed', () => { + it('is off for deployed apps by default', () => { + expect(isForcePluginsAllowed({ isDev: false, isLocal: false, appConfig: {} })).toBe(false); + }); + + it('is on for local/dev or Muse e2e', () => { + expect(isForcePluginsAllowed({ isDev: true })).toBe(true); + expect(isForcePluginsAllowed({ isLocal: true })).toBe(true); + expect(isForcePluginsAllowed({ isE2eTest: true })).toBe(true); + }); +}); + +describe('parseForcePlugins', () => { + it('parses scoped names, types, and multiple entries', () => { + expect(parseForcePlugins('@ebay/my-plugin!lib@1.2.3;other-plugin@2.0.0')).toEqual({ + '@ebay/my-plugin': { version: '1.2.3', type: 'lib' }, + 'other-plugin': { version: '2.0.0', type: undefined }, + }); + }); + + it('parses the pentest-style name!type@version form', () => { + expect(parseForcePlugins('poc!init@1.0.0')).toEqual({ + poc: { version: '1.0.0', type: 'init' }, + }); + }); +}); + +describe('applyForcePlugins', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const deployed = [ + { name: '@ebay/muse-lib-react', type: 'lib', version: '1.0.0' }, + { name: 'my-feature', type: 'normal', version: '2.0.0' }, + ]; + + it('overrides an existing plugin version', () => { + expect(applyForcePlugins(deployed, '@ebay/muse-lib-react@3.1.4')).toEqual([ + { name: '@ebay/muse-lib-react', type: 'lib', version: '3.1.4' }, + { name: 'my-feature', type: 'normal', version: '2.0.0' }, + ]); + }); + + it('removes a plugin when version is null', () => { + expect(applyForcePlugins(deployed, 'my-feature@null')).toEqual([ + { name: '@ebay/muse-lib-react', type: 'lib', version: '1.0.0' }, + ]); + }); + + it('adds a plugin only with a valid name, type, and version', () => { + expect(applyForcePlugins(deployed, 'extra!init@1.0.1')).toEqual([ + ...deployed, + { name: 'extra', type: 'init', version: '1.0.1' }, + ]); + }); + + it('drops XSS payloads instead of constructing plugin URLs from them', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const payload = 'poc!init@'; + expect(applyForcePlugins(deployed, payload)).toEqual(deployed); + expect(warn).toHaveBeenCalled(); + }); + + it('does not add a plugin without a valid type', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(applyForcePlugins(deployed, 'mystery@1.0.0')).toEqual(deployed); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/ui-plugins/muse-boot-default/src/loading.js b/ui-plugins/muse-boot-default/src/loading.js index eee192327..42241cd38 100644 --- a/ui-plugins/muse-boot-default/src/loading.js +++ b/ui-plugins/muse-boot-default/src/loading.js @@ -44,7 +44,7 @@ const loading = { }, showMessage(msg) { - if (this.labelNode) this.labelNode.innerHTML = msg || ''; + if (this.labelNode) this.labelNode.textContent = msg || ''; }, };