Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-devtools-module-info.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/devtools': patch
---

Guard Chrome DevTools module info sync against invalid undefined placeholder payloads.
193 changes: 193 additions & 0 deletions packages/chrome-devtools/__tests__/module-info-payload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

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(
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.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', () => {
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);
});

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);
});
});
5 changes: 3 additions & 2 deletions packages/chrome-devtools/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import LanguageSwitch from './component/LanguageSwitch';
import ThemeToggle from './component/ThemeToggle';
import {
getGlobalModuleInfo,
normalizeModuleInfoPayload,
refreshModuleInfo,
RootComponentProps,
separateType,
Expand All @@ -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 {};
}
};

Expand Down
87 changes: 67 additions & 20 deletions packages/chrome-devtools/src/utils/chrome/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ import { sanitizePostMessagePayload } from './safe-post-message';

export * from './storage';

const isModuleInfoRecord = (value: unknown): value is Record<string, unknown> =>
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 => {
const sanitized = sanitizePostMessagePayload(moduleInfo);
if (!isModuleInfoRecord(sanitized)) {
return {} as GlobalModuleInfo;
}
return Object.entries(sanitized).reduce<GlobalModuleInfo>(
(moduleMap, [moduleId, snapshot]) => {
if (moduleId === 'extendInfos' || isModuleInfoRecord(snapshot)) {
moduleMap[moduleId] = snapshot as GlobalModuleInfo[string];
}
return moduleMap;
},
{},
);
};

const sleep = (num: number) => {
return new Promise<void>((resolve) => {
setTimeout(() => {
Expand Down Expand Up @@ -109,47 +143,60 @@ 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);

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 = sanitizePostMessagePayload(
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 = sanitizePostMessagePayload(
window.__FEDERATION__.moduleInfo = normalizeModuleInfoPayload(
window.__FEDERATION__.originModuleInfo,
);
console.log('getGlobalModuleInfo window', window.__FEDERATION__);
Expand Down
13 changes: 11 additions & 2 deletions packages/chrome-devtools/src/utils/chrome/post-message-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)
: {};

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({
Expand All @@ -22,7 +31,7 @@ if (window.moduleHandler) {
return;
}

if (!data.moduleInfo) {
if (!isModuleInfoPayload(data.moduleInfo)) {
return;
}

Expand Down