diff --git a/doc/api/cli.md b/doc/api/cli.md index e546473ba3885e..8f206504a8cff0 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 248944097f6a3f..fa15074b2fc9b5 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 b4301b366cd768..4452c8ffef1205 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 1a217a8bcf900a..0dc21f513b60a6 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 b1ea26ab3e04b3..085908c1222202 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 5ffb48406323ae..408fdd650ab9ff 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 913366b3500442..f24afa417c870f 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 54710e487701dd..78e115cd74d068 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 00000000000000..7c6d6c73d3d5a7 --- /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 00000000000000..f2c75cf8e443b4 --- /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 00000000000000..645b2442543a5e --- /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' + });