From 466818af0e70757ecfb7cefe56d30af48c249729 Mon Sep 17 00:00:00 2001 From: dmchoi77 Date: Fri, 29 May 2026 22:00:55 +0900 Subject: [PATCH 1/2] fix(devtools): guard invalid module info payloads --- .changeset/quiet-devtools-module-info.md | 5 ++ .../__tests__/module-info-payload.spec.ts | 82 +++++++++++++++++++ packages/chrome-devtools/src/App.tsx | 5 +- .../chrome-devtools/src/utils/chrome/index.ts | 31 +++++-- .../src/utils/chrome/post-message-listener.ts | 13 ++- 5 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 .changeset/quiet-devtools-module-info.md create mode 100644 packages/chrome-devtools/__tests__/module-info-payload.spec.ts diff --git a/.changeset/quiet-devtools-module-info.md b/.changeset/quiet-devtools-module-info.md new file mode 100644 index 00000000000..5e59a64195a --- /dev/null +++ b/.changeset/quiet-devtools-module-info.md @@ -0,0 +1,5 @@ +--- +'@module-federation/devtools': patch +--- + +Guard Chrome DevTools module info sync against invalid undefined placeholder payloads. diff --git a/packages/chrome-devtools/__tests__/module-info-payload.spec.ts b/packages/chrome-devtools/__tests__/module-info-payload.spec.ts new file mode 100644 index 00000000000..8a887ecf386 --- /dev/null +++ b/packages/chrome-devtools/__tests__/module-info-payload.spec.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { normalizeModuleInfoPayload } from '../src/utils/chrome'; + +const dispatchModuleInfoMessage = (moduleInfo: unknown) => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + moduleInfo, + }, + origin: 'https://example.com', + }), + ); +}; + +const dispatchRawMessageData = (data: unknown) => { + window.dispatchEvent( + new MessageEvent('message', { + data, + origin: 'https://example.com', + }), + ); +}; + +describe('normalizeModuleInfoPayload', () => { + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + Reflect.deleteProperty(globalThis, 'chrome'); + Reflect.deleteProperty(window, 'moduleHandler'); + }); + + it('drops undefined placeholders produced by safe post message serialization', () => { + expect(normalizeModuleInfoPayload('[undefined]')).toEqual({}); + }); + + it('drops non-object module info payloads', () => { + expect(normalizeModuleInfoPayload(undefined)).toEqual({}); + expect(normalizeModuleInfoPayload(null)).toEqual({}); + expect(normalizeModuleInfoPayload([])).toEqual({}); + }); + + it('keeps valid module info snapshots', () => { + expect( + normalizeModuleInfoPayload({ + 'host:http://localhost:3000/mf-manifest.json': { + remotesInfo: {}, + }, + broken: '[undefined]', + }), + ).toEqual({ + 'host:http://localhost:3000/mf-manifest.json': { + remotesInfo: {}, + }, + }); + }); + + it('does not relay undefined placeholders from the page message listener', async () => { + const sendMessage = vi.fn(() => Promise.resolve()); + vi.stubGlobal('chrome', { + runtime: { + sendMessage, + }, + }); + + await import('../src/utils/chrome/post-message-listener'); + + dispatchModuleInfoMessage('[undefined]'); + expect(sendMessage).not.toHaveBeenCalled(); + + dispatchRawMessageData(null); + dispatchRawMessageData('[undefined]'); + expect(sendMessage).not.toHaveBeenCalled(); + + dispatchModuleInfoMessage({ + 'host:http://localhost:3000/mf-manifest.json': { + remotesInfo: {}, + }, + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/chrome-devtools/src/App.tsx b/packages/chrome-devtools/src/App.tsx index b01045e0e6d..767bccc85f9 100644 --- a/packages/chrome-devtools/src/App.tsx +++ b/packages/chrome-devtools/src/App.tsx @@ -16,6 +16,7 @@ import LanguageSwitch from './component/LanguageSwitch'; import ThemeToggle from './component/ThemeToggle'; import { getGlobalModuleInfo, + normalizeModuleInfoPayload, refreshModuleInfo, RootComponentProps, separateType, @@ -31,10 +32,10 @@ import btnStyles from './component/ThemeToggle.module.scss'; const cloneModuleInfo = (info?: GlobalModuleInfo | null): GlobalModuleInfo => { try { - return JSON.parse(JSON.stringify(info || {})); + return normalizeModuleInfoPayload(info); } catch (error) { console.warn('[MF Devtools] cloneModuleInfo failed', error); - return info || {}; + return {}; } }; diff --git a/packages/chrome-devtools/src/utils/chrome/index.ts b/packages/chrome-devtools/src/utils/chrome/index.ts index 25c2d315b84..238de0d4eed 100644 --- a/packages/chrome-devtools/src/utils/chrome/index.ts +++ b/packages/chrome-devtools/src/utils/chrome/index.ts @@ -5,6 +5,27 @@ import { sanitizePostMessagePayload } from './safe-post-message'; export * from './storage'; +const isModuleInfoRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +export const normalizeModuleInfoPayload = ( + moduleInfo: unknown, +): GlobalModuleInfo => { + const sanitized = sanitizePostMessagePayload(moduleInfo); + if (!isModuleInfoRecord(sanitized)) { + return {} as GlobalModuleInfo; + } + return Object.entries(sanitized).reduce( + (moduleMap, [moduleId, snapshot]) => { + if (moduleId === 'extendInfos' || isModuleInfoRecord(snapshot)) { + moduleMap[moduleId] = snapshot as GlobalModuleInfo[string]; + } + return moduleMap; + }, + {}, + ); +}; + const sleep = (num: number) => { return new Promise((resolve) => { setTimeout(() => { @@ -109,11 +130,7 @@ export const getGlobalModuleInfo = async ( callback: (moduleInfo: GlobalModuleInfo) => void, ) => { if (typeof window !== 'undefined' && window.__FEDERATION__?.moduleInfo) { - callback( - sanitizePostMessagePayload( - window.__FEDERATION__?.moduleInfo, - ) as GlobalModuleInfo, - ); + callback(normalizeModuleInfoPayload(window.__FEDERATION__?.moduleInfo)); } await sleep(300); @@ -127,7 +144,7 @@ export const getGlobalModuleInfo = async ( definePropertyGlobalVal(window, '__FEDERATION__', {}); definePropertyGlobalVal(window, '__VMOK__', window.__FEDERATION__); } - window.__FEDERATION__.originModuleInfo = sanitizePostMessagePayload( + window.__FEDERATION__.originModuleInfo = normalizeModuleInfoPayload( data?.moduleInfo, ); if (data?.updateModule) { @@ -149,7 +166,7 @@ export const getGlobalModuleInfo = async ( if (data?.share) { window.__FEDERATION__.__SHARE__ = sanitizePostMessagePayload(data.share); } - window.__FEDERATION__.moduleInfo = sanitizePostMessagePayload( + window.__FEDERATION__.moduleInfo = normalizeModuleInfoPayload( window.__FEDERATION__.originModuleInfo, ); console.log('getGlobalModuleInfo window', window.__FEDERATION__); diff --git a/packages/chrome-devtools/src/utils/chrome/post-message-listener.ts b/packages/chrome-devtools/src/utils/chrome/post-message-listener.ts index 7f5c06a4329..9980071f303 100644 --- a/packages/chrome-devtools/src/utils/chrome/post-message-listener.ts +++ b/packages/chrome-devtools/src/utils/chrome/post-message-listener.ts @@ -4,11 +4,20 @@ import { OBSERVABILITY_DEVTOOLS_SOURCE, } from './messages'; +const isModuleInfoPayload = (value: unknown) => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const getMessageData = (data: unknown) => + Boolean(data) && typeof data === 'object' + ? (data as Record) + : {}; + if (window.moduleHandler) { window.removeEventListener('message', window.moduleHandler); } else { window.moduleHandler = (event) => { - const { origin, data } = event; + const { origin } = event; + const data = getMessageData(event.data); if (data?.source === OBSERVABILITY_DEVTOOLS_SOURCE) { chrome.runtime .sendMessage({ @@ -22,7 +31,7 @@ if (window.moduleHandler) { return; } - if (!data.moduleInfo) { + if (!isModuleInfoPayload(data.moduleInfo)) { return; } From 2884962bcf9d89f6cc047a2c3e6a92fcbe1fc8ca Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 1 Jun 2026 21:54:18 +0200 Subject: [PATCH 2/2] fix(devtools): ignore non-module runtime messages --- .../__tests__/module-info-payload.spec.ts | 113 +++++++++++++++++- .../chrome-devtools/src/utils/chrome/index.ts | 58 ++++++--- 2 files changed, 156 insertions(+), 15 deletions(-) diff --git a/packages/chrome-devtools/__tests__/module-info-payload.spec.ts b/packages/chrome-devtools/__tests__/module-info-payload.spec.ts index 8a887ecf386..00bf9f8c387 100644 --- a/packages/chrome-devtools/__tests__/module-info-payload.spec.ts +++ b/packages/chrome-devtools/__tests__/module-info-payload.spec.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { normalizeModuleInfoPayload } from '../src/utils/chrome'; +import { + getGlobalModuleInfo, + normalizeModuleInfoPayload, +} from '../src/utils/chrome'; +import { + MESSAGE_ACTIVE_TAB_CHANGED, + MESSAGE_OBSERVABILITY_DEVTOOLS_EVENT, +} from '../src/utils/chrome/messages'; const dispatchModuleInfoMessage = (moduleInfo: unknown) => { window.dispatchEvent( @@ -24,10 +31,13 @@ const dispatchRawMessageData = (data: unknown) => { describe('normalizeModuleInfoPayload', () => { afterEach(() => { + vi.useRealTimers(); vi.resetModules(); vi.restoreAllMocks(); Reflect.deleteProperty(globalThis, 'chrome'); Reflect.deleteProperty(window, 'moduleHandler'); + Reflect.deleteProperty(window, '__FEDERATION__'); + Reflect.deleteProperty(window, '__VMOK__'); }); it('drops undefined placeholders produced by safe post message serialization', () => { @@ -79,4 +89,105 @@ describe('normalizeModuleInfoPayload', () => { }); expect(sendMessage).toHaveBeenCalledTimes(1); }); + + it('ignores unrelated runtime messages without clearing cached module info', async () => { + vi.useFakeTimers(); + + const moduleInfo = { + 'host:http://localhost:3000/mf-manifest.json': { + remotesInfo: {}, + }, + }; + window.__FEDERATION__ = { + moduleInfo, + } as any; + window.__VMOK__ = window.__FEDERATION__; + + let runtimeListener: + | ((message: { data?: unknown; type?: string; tabId?: number }) => void) + | undefined; + const addListener = vi.fn((listener) => { + runtimeListener = listener; + }); + const removeListener = vi.fn(); + + vi.stubGlobal('chrome', { + runtime: { + getURL: (file: string) => `chrome-extension://id/${file}`, + onMessage: { + addListener, + removeListener, + }, + }, + devtools: { + inspectedWindow: { + tabId: 1, + eval: vi.fn((_code: string, callback) => { + callback(true, undefined); + }), + }, + }, + tabs: { + query: vi.fn().mockResolvedValue([{ id: 1 }]), + }, + scripting: { + executeScript: vi.fn().mockResolvedValue([]), + }, + }); + + const callback = vi.fn(); + const cleanupPromise = getGlobalModuleInfo(callback); + + expect(callback).toHaveBeenCalledWith(moduleInfo); + await vi.advanceTimersByTimeAsync(300); + expect(addListener).toHaveBeenCalledTimes(1); + + runtimeListener?.({ + type: MESSAGE_ACTIVE_TAB_CHANGED, + tabId: 1, + }); + runtimeListener?.({ + type: MESSAGE_OBSERVABILITY_DEVTOOLS_EVENT, + data: { + kind: 'installed', + }, + }); + + expect(window.__FEDERATION__?.moduleInfo).toEqual(moduleInfo); + expect(callback).toHaveBeenCalledTimes(1); + + runtimeListener?.({ + data: { + share: { + default: {}, + }, + }, + }); + + expect(window.__FEDERATION__?.moduleInfo).toEqual(moduleInfo); + expect(window.__FEDERATION__?.__SHARE__).toEqual({ + default: {}, + }); + expect(callback).toHaveBeenCalledTimes(2); + + const nextModuleInfo = { + 'remote:http://localhost:3001/mf-manifest.json': { + remoteEntry: 'http://localhost:3001/remoteEntry.js', + version: 'http://localhost:3001/remoteEntry.js', + }, + }; + runtimeListener?.({ + data: { + moduleInfo: nextModuleInfo, + }, + }); + + expect(window.__FEDERATION__?.moduleInfo).toEqual(nextModuleInfo); + expect(callback).toHaveBeenCalledWith(nextModuleInfo); + + await vi.advanceTimersByTimeAsync(50); + const cleanup = await cleanupPromise; + cleanup(); + expect(removeListener).toHaveBeenCalledWith(runtimeListener); + }); }); diff --git a/packages/chrome-devtools/src/utils/chrome/index.ts b/packages/chrome-devtools/src/utils/chrome/index.ts index 238de0d4eed..d756d9edb2d 100644 --- a/packages/chrome-devtools/src/utils/chrome/index.ts +++ b/packages/chrome-devtools/src/utils/chrome/index.ts @@ -8,6 +8,19 @@ export * from './storage'; const isModuleInfoRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); +type ModuleInfoSyncMessage = { + moduleInfo?: unknown; + updateModule?: unknown; + share?: unknown; + appInfos?: unknown; +}; + +const isModuleInfoSyncMessage = ( + value: unknown, +): value is ModuleInfoSyncMessage => + isModuleInfoRecord(value) && + ('moduleInfo' in value || 'updateModule' in value || 'share' in value); + export const normalizeModuleInfoPayload = ( moduleInfo: unknown, ): GlobalModuleInfo => { @@ -137,34 +150,51 @@ export const getGlobalModuleInfo = async ( const listener = (message: { origin: string; data: any }) => { const { data } = message; - if (!data || data?.appInfos) { + if (!isModuleInfoSyncMessage(data) || data?.appInfos) { return; } if (!window?.__FEDERATION__) { definePropertyGlobalVal(window, '__FEDERATION__', {}); definePropertyGlobalVal(window, '__VMOK__', window.__FEDERATION__); } - window.__FEDERATION__.originModuleInfo = normalizeModuleInfoPayload( - data?.moduleInfo, - ); - if (data?.updateModule) { + window.__FEDERATION__.originModuleInfo = + 'moduleInfo' in data + ? normalizeModuleInfoPayload(data.moduleInfo) + : normalizeModuleInfoPayload( + window.__FEDERATION__.originModuleInfo || + window.__FEDERATION__.moduleInfo, + ); + const updateModule = data.updateModule; + if ( + isModuleInfoRecord(updateModule) && + typeof updateModule.name === 'string' + ) { + const updateModuleName = updateModule.name; const moduleIds = Object.keys(window.__FEDERATION__.originModuleInfo); const shouldUpdate = !moduleIds.some((id) => - id.includes(data.updateModule.name), + id.includes(updateModuleName), ); if (shouldUpdate) { const destination = - data.updateModule.entry || data.updateModule.version; - window.__FEDERATION__.originModuleInfo[ - `${data.updateModule.name}:${destination}` - ] = { - remoteEntry: destination, - version: destination, - }; + typeof updateModule.entry === 'string' + ? updateModule.entry + : typeof updateModule.version === 'string' + ? updateModule.version + : undefined; + if (destination) { + window.__FEDERATION__.originModuleInfo[ + `${updateModuleName}:${destination}` + ] = { + remoteEntry: destination, + version: destination, + }; + } } } if (data?.share) { - window.__FEDERATION__.__SHARE__ = sanitizePostMessagePayload(data.share); + window.__FEDERATION__.__SHARE__ = sanitizePostMessagePayload( + data.share, + ) as typeof window.__FEDERATION__.__SHARE__; } window.__FEDERATION__.moduleInfo = normalizeModuleInfoPayload( window.__FEDERATION__.originModuleInfo,