Skip to content

Commit eec5e95

Browse files
author
ScriptedAlchemy
committed
fix(node): harden native loader allowlist and simplify loader-hooks surface
Security fixes from consolidated code review of the native HTTP ESM loader: - gate registration itself on the MF_NODE_NATIVE_LOADER kill switch so module.register() is never called when disabled - share one in-flight ack per origin in allowOrigin() so concurrent callers cannot resolve before the hooks thread applied the allowlist update - reject HTTP redirects in the hooks-thread fetch so an allowlisted origin cannot redirect module source to an un-allowlisted target - bound the hooks fetch (30s abort) and allowlist acks (10s rejection) so a broken port fails loudly instead of hanging the process Cleanup: reuse RemoteEntryExports from @module-federation/runtime/types, drop the always-true state.enabled flag, fold state.ts into protocol.ts, type the hooks with @types/node module hook types, stop re-exporting test-only helpers, resolve the CJS register hooksUrl via defaultHooksUrl(), share the log prefix constant, warn when smoke tests skip on missing dist, and fix a stale comment in the node-esm-host example.
1 parent ce100b3 commit eec5e95

14 files changed

Lines changed: 279 additions & 168 deletions

File tree

apps/node-esm-host/webpack.config.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ const path = require('path');
55
// The host bundle itself is a native ES module (output.module +
66
// chunkFormat/chunkLoading 'module'/'import'). The Module Federation runtime
77
// is bundled in; the remote entry URL is imported at runtime with a native
8-
// dynamic `import()` (see src/native-esm-load-entry-plugin.js), which is what
9-
// routes remote loading through Node's http(s) loader hooks instead of the
10-
// vm-based 'async-node' machinery used by the sibling node-host example.
8+
// dynamic `import()` (the node runtime plugin's `loadEntry` bridge, see
9+
// @module-federation/node/runtimePlugin), which is what routes remote loading
10+
// through Node's http(s) loader hooks instead of the vm-based 'async-node'
11+
// machinery used by the sibling node-host example.
1112
module.exports = (_env, argv = {}) => {
1213
const isProduction = argv.mode === 'production';
1314

packages/node/src/__tests__/fixtures/native-loader-smoke.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ const origin = `http://127.0.0.1:${server.address().port}`;
3131

3232
const distSrc = new URL(process.env.MF_NODE_DIST_SRC);
3333
const { getNativeHttpLoaderState } = await import(
34-
new URL('loader-hooks/state.mjs', distSrc)
34+
new URL('loader-hooks/protocol.mjs', distSrc)
3535
);
3636
const { loadEntryViaNativeHttpLoader } = await import(
3737
new URL('loader-hooks/entryLoader.mjs', distSrc)
3838
);
3939

4040
const state = getNativeHttpLoaderState();
41-
assert.ok(state?.enabled, 'register entry point must create loader state');
41+
assert.ok(state, 'register entry point must create loader state');
4242

4343
// Origins outside the allowlist must be refused.
4444
await assert.rejects(

packages/node/src/__tests__/loader-hooks.test.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { MessageChannel } from 'node:worker_threads';
2+
import type { LoadHookContext, ResolveHookContext } from 'node:module';
23
import {
34
initialize,
45
load,
@@ -10,15 +11,12 @@ import {
1011
import {
1112
ACK_MESSAGE,
1213
ADD_ORIGIN_MESSAGE,
13-
MF_NATIVE_LOADER_ENV_FLAG,
1414
isHttpUrl,
1515
normalizeOrigin,
16-
} from '../loader-hooks/protocol';
17-
import {
1816
getNativeHttpLoaderState,
1917
setNativeHttpLoaderState,
2018
clearNativeHttpLoaderStateForTesting,
21-
} from '../loader-hooks/state';
19+
} from '../loader-hooks/protocol';
2220
import { loadEntryViaNativeHttpLoader } from '../loader-hooks/entryLoader';
2321
import type { NativeHttpLoaderState } from '../loader-hooks/protocol';
2422

@@ -33,14 +31,17 @@ const originalFetch = global.fetch;
3331
afterEach(() => {
3432
resetNativeHttpLoaderStateForTesting();
3533
clearNativeHttpLoaderStateForTesting();
36-
delete process.env[MF_NATIVE_LOADER_ENV_FLAG];
3734
global.fetch = originalFetch;
3835
jest.clearAllMocks();
3936
});
4037

4138
function mockFetchResponse(
4239
body: string,
43-
init: { ok?: boolean; status?: number; contentType?: string | null } = {},
40+
init: {
41+
ok?: boolean;
42+
status?: number;
43+
contentType?: string | null;
44+
} = {},
4445
): jest.Mock {
4546
const { ok = true, status = 200, contentType = 'text/javascript' } = init;
4647
const fetchMock = jest.fn().mockResolvedValue({
@@ -94,7 +95,12 @@ describe('loader hooks: initialize', () => {
9495
});
9596

9697
describe('loader hooks: resolve', () => {
97-
const context = { conditions: [], importAttributes: {} };
98+
const context: ResolveHookContext = {
99+
conditions: [],
100+
importAssertions: {},
101+
importAttributes: {},
102+
parentURL: undefined,
103+
};
98104

99105
it('short-circuits allowed http specifiers', async () => {
100106
initialize({ allowedOrigins: [ORIGIN] });
@@ -161,7 +167,12 @@ describe('loader hooks: resolve', () => {
161167
});
162168

163169
describe('loader hooks: load', () => {
164-
const context = { conditions: [], importAttributes: {} };
170+
const context: LoadHookContext = {
171+
conditions: [],
172+
format: undefined,
173+
importAssertions: {},
174+
importAttributes: {},
175+
};
165176

166177
it('passes non-http URLs through to the default loader', async () => {
167178
nextLoad.mockResolvedValue({ format: 'module', source: '' });
@@ -178,7 +189,13 @@ describe('loader hooks: load', () => {
178189
source: 'export const x = 1;',
179190
shortCircuit: true,
180191
});
181-
expect(fetchMock).toHaveBeenCalledWith(ENTRY_URL);
192+
expect(fetchMock).toHaveBeenCalledWith(
193+
ENTRY_URL,
194+
expect.objectContaining({
195+
redirect: 'manual',
196+
signal: expect.any(AbortSignal),
197+
}),
198+
);
182199
expect(nextLoad).not.toHaveBeenCalled();
183200
});
184201

@@ -199,6 +216,18 @@ describe('loader hooks: load', () => {
199216
expect(fetchMock).not.toHaveBeenCalled();
200217
});
201218

219+
it('refuses to follow redirects from allowlisted origins', async () => {
220+
initialize({ allowedOrigins: [ORIGIN] });
221+
const fetchMock = mockFetchResponse('export const stolen = 1;', {
222+
ok: false,
223+
status: 302,
224+
});
225+
await expect(load(ENTRY_URL, context, nextLoad)).rejects.toThrow(
226+
/redirect/i,
227+
);
228+
expect(fetchMock).toHaveBeenCalledTimes(1);
229+
});
230+
202231
it('throws a descriptive error on non-OK responses', async () => {
203232
initialize({ allowedOrigins: [ORIGIN] });
204233
mockFetchResponse('', { ok: false, status: 404 });
@@ -220,7 +249,6 @@ describe('native loader global state', () => {
220249
it('stores and clears state on globalThis', () => {
221250
expect(getNativeHttpLoaderState()).toBeUndefined();
222251
const state: NativeHttpLoaderState = {
223-
enabled: true,
224252
allowedOrigins: new Set(),
225253
allowOrigin: jest.fn().mockResolvedValue(undefined),
226254
};
@@ -236,7 +264,6 @@ describe('loadEntryViaNativeHttpLoader', () => {
236264

237265
function activateState(): NativeHttpLoaderState {
238266
const state: NativeHttpLoaderState = {
239-
enabled: true,
240267
allowedOrigins: new Set(),
241268
allowOrigin: jest.fn().mockResolvedValue(undefined),
242269
};
@@ -250,15 +277,6 @@ describe('loadEntryViaNativeHttpLoader', () => {
250277
);
251278
});
252279

253-
it('returns undefined when disabled through the env flag', async () => {
254-
const state = activateState();
255-
process.env[MF_NATIVE_LOADER_ENV_FLAG] = '0';
256-
await expect(loadEntryViaNativeHttpLoader(remoteInfo)).resolves.toBe(
257-
undefined,
258-
);
259-
expect(state.allowOrigin).not.toHaveBeenCalled();
260-
});
261-
262280
it('returns undefined for non-http entries', async () => {
263281
activateState();
264282
await expect(

packages/node/src/__tests__/register-smoke.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ const hooksSupported =
1919
typeof (nodeModule as { register?: unknown }).register === 'function';
2020

2121
const describeSmoke = distAvailable ? describe : describe.skip;
22+
if (!distAvailable) {
23+
console.warn(
24+
`[@module-federation/node] register-smoke tests skipped: built dist not ` +
25+
`found at ${distSrc}. Run the package build first to exercise them.`,
26+
);
27+
}
2228

2329
function runNode(args: string[], env: NodeJS.ProcessEnv = {}): string {
2430
return execFileSync(process.execPath, args, {

packages/node/src/__tests__/register.test.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,20 @@ import {
1515
resetNativeHttpLoaderStateForTesting,
1616
} from '../loader-hooks/hooks';
1717
import {
18+
MF_NATIVE_LOADER_ENV_FLAG,
1819
MF_NATIVE_LOADER_HOSTS_ENV,
20+
clearNativeHttpLoaderStateForTesting,
21+
getNativeHttpLoaderState,
1922
type NativeHttpLoaderInitializeData,
2023
} from '../loader-hooks/protocol';
21-
import { clearNativeHttpLoaderStateForTesting } from '../loader-hooks/state';
2224

2325
const HOOKS_URL = 'file:///fake/loader-hooks/hooks.mjs';
2426

2527
afterEach(() => {
2628
clearNativeHttpLoaderStateForTesting();
2729
resetNativeHttpLoaderStateForTesting();
2830
delete process.env[MF_NATIVE_LOADER_HOSTS_ENV];
31+
delete process.env[MF_NATIVE_LOADER_ENV_FLAG];
2932
jest.clearAllMocks();
3033
});
3134

@@ -34,6 +37,19 @@ describe('registerNativeHttpLoader', () => {
3437
expect(isNativeHttpLoaderSupported()).toBe(true);
3538
});
3639

40+
it.each(['0', 'false'])(
41+
'is a no-op when disabled via %s in the env kill switch',
42+
(flag) => {
43+
process.env[MF_NATIVE_LOADER_ENV_FLAG] = flag;
44+
45+
const state = registerNativeHttpLoader({ hooksUrl: HOOKS_URL });
46+
47+
expect(state).toBeUndefined();
48+
expect(mockRegister).not.toHaveBeenCalled();
49+
expect(getNativeHttpLoaderState()).toBeUndefined();
50+
},
51+
);
52+
3753
it('registers the hooks module once and seeds allowed origins', () => {
3854
process.env[MF_NATIVE_LOADER_HOSTS_ENV] =
3955
'https://cdn.example.com, invalid entry,';
@@ -54,19 +70,26 @@ describe('registerNativeHttpLoader', () => {
5470
);
5571
expect(options.transferList).toContain(options.data.port);
5672

57-
expect(state?.enabled).toBe(true);
73+
expect(state).toBeDefined();
5874
expect(state?.allowedOrigins.has('https://cdn.example.com')).toBe(true);
5975
});
6076

61-
it('is idempotent and merges new origins into the existing registration', () => {
77+
it('is idempotent and merges new origins into the existing registration', async () => {
6278
const first = registerNativeHttpLoader({ hooksUrl: HOOKS_URL });
79+
// Wire the hooks side so the merge's allowOrigin round-trips get acked.
80+
initialize(
81+
mockRegister.mock.calls[0][1].data as NativeHttpLoaderInitializeData,
82+
);
83+
6384
const second = registerNativeHttpLoader({
6485
hooksUrl: HOOKS_URL,
6586
allowedOrigins: ['https://late.example.com'],
6687
});
6788

6889
expect(second).toBe(first);
6990
expect(mockRegister).toHaveBeenCalledTimes(1);
91+
// The merged origin only lands once the hooks thread acks it.
92+
await first!.allowOrigin('https://late.example.com');
7093
expect(first?.allowedOrigins.has('https://late.example.com')).toBe(true);
7194
});
7295

@@ -89,4 +112,35 @@ describe('registerNativeHttpLoader', () => {
89112
state!.allowOrigin('https://dynamic.example.com'),
90113
).resolves.toBeUndefined();
91114
});
115+
116+
it('does not resolve concurrent allowOrigin calls for the same origin before the ack', async () => {
117+
const state = registerNativeHttpLoader({ hooksUrl: HOOKS_URL });
118+
const data = mockRegister.mock.calls[0][1]
119+
.data as NativeHttpLoaderInitializeData;
120+
121+
// No hooks side wired up yet: no ack can arrive.
122+
const first = state!.allowOrigin('https://dynamic.example.com');
123+
const second = state!.allowOrigin('https://dynamic.example.com/x.js');
124+
125+
let settled = false;
126+
void first.then(() => {
127+
settled = true;
128+
});
129+
void second.then(() => {
130+
settled = true;
131+
});
132+
await new Promise((r) => setTimeout(r, 20));
133+
expect(settled).toBe(false);
134+
expect(state!.allowedOrigins.has('https://dynamic.example.com')).toBe(
135+
false,
136+
);
137+
138+
// Wiring up the hooks side delivers the queued message and acks it; only
139+
// then may callers proceed to import().
140+
initialize(data);
141+
await expect(first).resolves.toBeUndefined();
142+
await expect(second).resolves.toBeUndefined();
143+
expect(state!.allowedOrigins.has('https://dynamic.example.com')).toBe(true);
144+
expect(isOriginAllowed('https://dynamic.example.com/chunk.js')).toBe(true);
145+
});
92146
});

packages/node/src/loader-hooks/entryLoader.ts

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,31 @@
1-
import { MF_NATIVE_LOADER_ENV_FLAG, isHttpUrl } from './protocol';
2-
import { getNativeHttpLoaderState } from './state';
3-
4-
/**
5-
* Structural subset of the MF runtime's `RemoteEntryExports` /
6-
* `RemoteInfo` types, kept local so this module can be bundled into
7-
* application code without pulling runtime-core type dependencies.
8-
*/
9-
export interface NativeRemoteEntryExports {
10-
get: (id: string) => () => Promise<unknown>;
11-
init: (...args: unknown[]) => void | Promise<void>;
12-
}
13-
14-
export interface NativeLoaderRemoteInfo {
15-
name: string;
16-
entry: string;
17-
type?: string;
18-
}
1+
import {
2+
MF_NODE_LOG_PREFIX,
3+
getNativeHttpLoaderState,
4+
isHttpUrl,
5+
} from './protocol';
6+
import type { RemoteEntryExports } from '@module-federation/runtime/types';
197

208
const NATIVE_COMPATIBLE_REMOTE_TYPES = new Set(['module', 'esm']);
219

2210
// Indirect dynamic import so bundlers (webpack/rspack) leave it untouched and
2311
// the request flows through Node's module loader — and thus our hooks.
12+
// Known limitation: this breaks under `--disallow-code-generation-from-strings`
13+
// (new Function is code generation from a string); there is no bundler-proof
14+
// alternative today.
2415
const dynamicImport = (url: string): Promise<Record<string, unknown>> =>
2516
new Function('u', 'return import(u)')(url);
2617

27-
function isNativeLoaderDisabledByEnv(): boolean {
28-
const flag = process.env[MF_NATIVE_LOADER_ENV_FLAG];
29-
return flag === '0' || flag === 'false';
30-
}
31-
3218
function pickContainer(
3319
namespace: Record<string, unknown>,
34-
): NativeRemoteEntryExports | undefined {
20+
): RemoteEntryExports | undefined {
3521
const candidates = [namespace, namespace['default']];
3622
for (const candidate of candidates) {
3723
if (
3824
candidate &&
39-
typeof (candidate as NativeRemoteEntryExports).get === 'function' &&
40-
typeof (candidate as NativeRemoteEntryExports).init === 'function'
25+
typeof (candidate as RemoteEntryExports).get === 'function' &&
26+
typeof (candidate as RemoteEntryExports).init === 'function'
4127
) {
42-
return candidate as NativeRemoteEntryExports;
28+
return candidate as RemoteEntryExports;
4329
}
4430
}
4531
return undefined;
@@ -50,17 +36,16 @@ function pickContainer(
5036
* routed through the hooks registered by `registerNativeHttpLoader`).
5137
*
5238
* Returns `undefined` when the native path does not apply — no registration,
53-
* disabled via env, non-HTTP entry, or a remote entry format that requires
54-
* the default vm-based path — so the MF runtime falls back to its default
55-
* entry loading.
39+
* non-HTTP entry, or a remote entry format that requires the default vm-based
40+
* path — so the MF runtime falls back to its default entry loading.
5641
*/
5742
export async function loadEntryViaNativeHttpLoader(remoteInfo: {
5843
name: string;
5944
entry: string;
6045
type?: string;
61-
}): Promise<NativeRemoteEntryExports | undefined> {
46+
}): Promise<RemoteEntryExports | undefined> {
6247
const state = getNativeHttpLoaderState();
63-
if (!state?.enabled || isNativeLoaderDisabledByEnv()) {
48+
if (!state) {
6449
return undefined;
6550
}
6651

@@ -78,7 +63,7 @@ export async function loadEntryViaNativeHttpLoader(remoteInfo: {
7863

7964
if (!container) {
8065
throw new Error(
81-
`[@module-federation/node] Remote "${name}" was imported from "${entry}" via the ` +
66+
`${MF_NODE_LOG_PREFIX} Remote "${name}" was imported from "${entry}" via the ` +
8267
`native HTTP loader but does not expose a federation container ` +
8368
`(expected "get" and "init" exports). Check that the remote is built ` +
8469
`with library.type "module".`,

0 commit comments

Comments
 (0)