diff --git a/doc/api/cli.md b/doc/api/cli.md index e546473ba388..8f206504a8cf 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -64,6 +64,18 @@ If this flag is passed, the behavior can still be set to not abort through [`process.setUncaughtExceptionCaptureCallback()`][] (and through usage of the `domain` module that uses it). +### `--allowed-module-location` + + +Only allow loading modules from given path. Can be used multiple times to set +multiple allowed module locations. + +If set, before loading any module Node.js will first check if given module is +inside any of the allowed directories (including sub-directories). If not it +will throw [`ERR_MODULE_BLOCKED`][] error. + ### `--completion-bash` +* `--allowed-module-location` * `--disable-proto` * `--enable-fips` * `--enable-source-maps` @@ -1567,6 +1580,7 @@ $ node --max-old-space-size=1536 index.js [`unhandledRejection`]: process.html#process_event_unhandledrejection [`worker_threads.threadId`]: worker_threads.html##worker_threads_worker_threadid [Chrome DevTools Protocol]: https://chromedevtools.github.io/devtools-protocol/ +[`ERR_MODULE_BLOCKED`]: errors.html#err_module_blocked [REPL]: repl.html [ScriptCoverage]: https://chromedevtools.github.io/devtools-protocol/tot/Profiler#type-ScriptCoverage [Source Map]: https://sourcemaps.info/spec.html diff --git a/doc/api/errors.md b/doc/api/errors.md index 248944097f6a..fa15074b2fc9 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -1641,6 +1641,12 @@ The V8 platform used by this instance of Node.js does not support creating Workers. This is caused by lack of embedder support for Workers. In particular, this error will not occur with standard builds of Node.js. + +### `ERR_MODULE_BLOCKED` + +Module was found but loading was blocked because module path was not included in +the [`--allowed-module-location`][] option. + ### `ERR_MODULE_NOT_FOUND` @@ -2545,6 +2551,7 @@ Used when an attempt is made to use a `zlib` object after it has already been closed. [`'uncaughtException'`]: process.html#process_event_uncaughtexception +[`--allowed-module-location`]: cli.html#allowed_module_location [`--disable-proto=throw`]: cli.html#cli_disable_proto_mode [`--force-fips`]: cli.html#cli_force_fips [`Class: assert.AssertionError`]: assert.html#assert_class_assert_assertionerror diff --git a/doc/api/modules.md b/doc/api/modules.md index b4301b366cd7..4452c8ffef12 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -384,6 +384,10 @@ either be a core module or is loaded from a `node_modules` folder. If the given path does not exist, `require()` will throw an [`Error`][] with its `code` property set to `'MODULE_NOT_FOUND'`. +If [`--allowed-module-location`][] was used and the given file is not inside +one of the allowed module locations, `require()` will throw an +[`ERR_MODULE_BLOCKED`][] error. + ## Folders as modules @@ -1137,6 +1141,7 @@ consists of the following keys: * originalLine: {number} * originalColumn: {number} +[`--allowed-module-location`]: cli.html#allowed_module_location [GLOBAL_FOLDERS]: #modules_loading_from_the_global_folders [`Error`]: errors.html#errors_class_error [`__dirname`]: #modules_dirname @@ -1147,6 +1152,7 @@ consists of the following keys: [`module.children`]: #modules_module_children [`path.dirname()`]: path.html#path_path_dirname_path [ECMAScript Modules]: esm.html +[`ERR_MODULE_BLOCKED`]: errors.html#err_module_blocked [an error]: errors.html#errors_err_require_esm [exports shortcut]: #modules_exports_shortcut [module resolution]: #modules_all_together diff --git a/doc/node.1 b/doc/node.1 index 1a217a8bcf90..0dc21f513b60 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -72,6 +72,9 @@ Pass the rest of the arguments to the script. If no script filename or eval/print script is supplied prior to this, then the next argument will be used as a script filename. . +.If Fl -allowed-module-list +Set allowed locations from which modules can be loaded (option can be repeated). +. .It Fl -abort-on-uncaught-exception Aborting instead of exiting causes a core file to be generated for analysis. . diff --git a/lib/internal/errors.js b/lib/internal/errors.js index b1ea26ab3e04..085908c12222 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1252,6 +1252,9 @@ E('ERR_MISSING_ARGS', return `${msg} must be specified`; }, TypeError); E('ERR_MISSING_OPTION', '%s is required', TypeError); +E('ERR_MODULE_BLOCKED', + 'Loading of module %s was blocked: module path is outside allowed locations', + Error); E('ERR_MODULE_NOT_FOUND', (path, base, type = 'package') => { return `Cannot find ${type} '${path}' imported from ${base}`; }, Error); diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 5ffb48406323..408fdd650ab9 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -84,6 +84,7 @@ const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); const manifest = getOptionValue('--experimental-policy') ? require('internal/process/policy').manifest : null; +const allowedModuleLocations = getOptionValue('--allowed-module-location'); const { compileFunction } = internalBinding('contextify'); // Whether any user-provided CJS modules had been loaded (executed). @@ -91,6 +92,7 @@ const { compileFunction } = internalBinding('contextify'); let hasLoadedAnyUserCJSModule = false; const { + ERR_MODULE_BLOCKED, ERR_INVALID_ARG_VALUE, ERR_INVALID_OPT_VALUE, ERR_INVALID_PACKAGE_CONFIG, @@ -937,6 +939,28 @@ Module._load = function(request, parent, isMain) { const mod = loadNativeModule(filename, request); if (mod && mod.canBeRequiredByUsers) return mod.exports; + // Only return the file location if it is in an allowed location + // or the allowed locations are not set + let locationAllowed = true; + if (allowedModuleLocations && allowedModuleLocations.length > 0) { + locationAllowed = false; + debug('Allowed module locations count: %d', allowedModuleLocations.length); + for (const allowedLocation of allowedModuleLocations) { + const relative = path.relative(allowedLocation, filename); + debug('Relative path from allowed location "%s" to loaded "%s": %s', + allowedLocation, filename, relative); + if (relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative))) { + debug('Module will be allowed'); + locationAllowed = true; + break; + } + } + } + if (!locationAllowed) { + throw new ERR_MODULE_BLOCKED(request); + } + // Don't call updateChildren(), Module constructor already does. const module = new Module(filename, parent); diff --git a/src/node_options.cc b/src/node_options.cc index 913366b35004..f24afa417c87 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -538,6 +538,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "set default TLS maximum to TLSv1.3 (default: TLSv1.3)", &EnvironmentOptions::tls_max_v1_3, kAllowedInEnvironment); + + AddOption("--allowed-module-location", + "set allowed locations from which modules can be loaded (option can be repeated)", + &EnvironmentOptions::allowed_module_locations, + kAllowedInEnvironment); } PerIsolateOptionsParser::PerIsolateOptionsParser( diff --git a/src/node_options.h b/src/node_options.h index 54710e487701..78e115cd74d0 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -169,6 +169,8 @@ class EnvironmentOptions : public Options { std::vector preload_modules; + std::vector allowed_module_locations; + std::vector user_argv; inline DebugOptions* get_debug_options() { return &debug_options_; } diff --git a/test/fixtures/allowed-modules/index.js b/test/fixtures/allowed-modules/index.js new file mode 100644 index 000000000000..7c6d6c73d3d5 --- /dev/null +++ b/test/fixtures/allowed-modules/index.js @@ -0,0 +1 @@ +module.exports = {} \ No newline at end of file diff --git a/test/fixtures/allowed-modules/package.json b/test/fixtures/allowed-modules/package.json new file mode 100644 index 000000000000..f2c75cf8e443 --- /dev/null +++ b/test/fixtures/allowed-modules/package.json @@ -0,0 +1,3 @@ +{ + "main": "index.js" +} \ No newline at end of file diff --git a/test/parallel/test-allowed-module-location.js b/test/parallel/test-allowed-module-location.js new file mode 100644 index 000000000000..645b2442543a --- /dev/null +++ b/test/parallel/test-allowed-module-location.js @@ -0,0 +1,41 @@ +'use strict'; +// Flags: --expose-internals +require('../common'); +const { getOptionValue } = require('internal/options'); +const assert = require('assert'); +const path = require('path'); +const cp = require('child_process'); + +if (getOptionValue('--allowed-module-location').length === 0) { + const args = [ + '--expose-internals', + '--allowed-module-location', __dirname, + '--allowed-module-location', path.join(__dirname, '..', 'common'), + __filename + ]; + const result = cp.spawnSync(process.argv0, + args, + { stdio: 'inherit', shell: false }); + assert.strictEqual(result.status, 0); + process.exit(0); +} + +const requireModule = path.join('..', 'fixtures', 'allowed-modules'); +assert.throws( + () => { require(requireModule); }, + { + code: 'ERR_MODULE_BLOCKED', + name: 'Error', + message: `Loading of module ${requireModule} was blocked: ` + + 'module path is outside allowed locations' + }); + +const requireFile = path.join(requireModule, 'index.js'); +assert.throws( + () => { require(requireFile); }, + { + code: 'ERR_MODULE_BLOCKED', + name: 'Error', + message: `Loading of module ${requireFile} was blocked: ` + + 'module path is outside allowed locations' + });