diff --git a/benchmark/fs/readfile-permission-enabled.js b/benchmark/fs/readfile-permission-enabled.js new file mode 100644 index 00000000000000..3053d5aa08f055 --- /dev/null +++ b/benchmark/fs/readfile-permission-enabled.js @@ -0,0 +1,72 @@ +// Call fs.readFile with permission system enabled +// over and over again really fast. +// Then see how many times it got called. +'use strict'; + +const path = require('path'); +const common = require('../common.js'); +const fs = require('fs'); +const assert = require('assert'); + +const tmpdir = require('../../test/common/tmpdir'); +tmpdir.refresh(); +const filename = path.resolve(tmpdir.path, + `.removeme-benchmark-garbage-${process.pid}`); + +const bench = common.createBenchmark(main, { + duration: [5], + encoding: ['', 'utf-8'], + len: [1024, 16 * 1024 * 1024], + concurrent: [1, 10], +}, { + flags: ['--experimental-permission', '--allow-fs-read=*', '--allow-fs-write=*'], +}); + +function main({ len, duration, concurrent, encoding }) { + try { + fs.unlinkSync(filename); + } catch { + // Continue regardless of error. + } + let data = Buffer.alloc(len, 'x'); + fs.writeFileSync(filename, data); + data = null; + + let reads = 0; + let benchEnded = false; + bench.start(); + setTimeout(() => { + benchEnded = true; + bench.end(reads); + try { + fs.unlinkSync(filename); + } catch { + // Continue regardless of error. + } + process.exit(0); + }, duration * 1000); + + function read() { + fs.readFile(filename, encoding, afterRead); + } + + function afterRead(er, data) { + if (er) { + if (er.code === 'ENOENT') { + // Only OK if unlinked by the timer from main. + assert.ok(benchEnded); + return; + } + throw er; + } + + if (data.length !== len) + throw new Error('wrong number of bytes returned'); + + reads++; + if (!benchEnded) + read(); + } + + while (concurrent--) read(); +} diff --git a/benchmark/permission/permission-fs-deny.js b/benchmark/permission/permission-fs-deny.js new file mode 100644 index 00000000000000..29bbeb27dc7c97 --- /dev/null +++ b/benchmark/permission/permission-fs-deny.js @@ -0,0 +1,19 @@ +'use strict'; +const common = require('../common.js'); + +const configs = { + n: [1e5], + concurrent: [1, 10], +}; + +const options = { flags: ['--experimental-permission'] }; + +const bench = common.createBenchmark(main, configs, options); + +async function main(conf) { + bench.start(); + for (let i = 0; i < conf.n; i++) { + process.permission.deny('fs.read', ['/home/example-file-' + i]); + } + bench.end(conf.n); +} diff --git a/benchmark/permission/permission-fs-is-granted.js b/benchmark/permission/permission-fs-is-granted.js new file mode 100644 index 00000000000000..062ba1944578f4 --- /dev/null +++ b/benchmark/permission/permission-fs-is-granted.js @@ -0,0 +1,50 @@ +'use strict'; +const common = require('../common.js'); +const fs = require('fs/promises'); +const path = require('path'); + +const configs = { + n: [1e5], + concurrent: [1, 10], +}; + +const rootPath = path.resolve(__dirname, '../../..'); + +const options = { + flags: [ + '--experimental-permission', + `--allow-fs-read=${rootPath}`, + ], +}; + +const bench = common.createBenchmark(main, configs, options); + +const recursivelyDenyFiles = async (dir) => { + const files = await fs.readdir(dir, { withFileTypes: true }); + for (const file of files) { + if (file.isDirectory()) { + await recursivelyDenyFiles(path.join(dir, file.name)); + } else if (file.isFile()) { + process.permission.deny('fs.read', [path.join(dir, file.name)]); + } + } +}; + +async function main(conf) { + const benchmarkDir = path.join(__dirname, '../..'); + // Get all the benchmark files and deny access to it + await recursivelyDenyFiles(benchmarkDir); + + bench.start(); + + for (let i = 0; i < conf.n; i++) { + // Valid file in a sequence of denied files + process.permission.has('fs.read', benchmarkDir + '/valid-file'); + // Denied file + process.permission.has('fs.read', __filename); + // Valid file a granted directory + process.permission.has('fs.read', '/tmp/example'); + } + + bench.end(conf.n); +} diff --git a/doc/api/cli.md b/doc/api/cli.md index c3544d5880b16f..f55b0ed379fa2f 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -100,6 +100,154 @@ If this flag is passed, the behavior can still be set to not abort through [`process.setUncaughtExceptionCaptureCallback()`][] (and through usage of the `node:domain` module that uses it). +### `--allow-child-process` + + + +> Stability: 1 - Experimental + +When using the [Permission Model][], the process will not be able to spawn any +child process by default. +Attempts to do so will throw an `ERR_ACCESS_DENIED` unless the +user explicitly passes the `--allow-child-process` flag when starting Node.js. + +Example: + +```js +const childProcess = require('node:child_process'); +// Attempt to bypass the permission +childProcess.spawn('node', ['-e', 'require("fs").writeFileSync("/new-file", "example")']); +``` + +```console +$ node --experimental-permission --allow-fs-read=* index.js +node:internal/child_process:388 + const err = this._handle.spawn(options); + ^ +Error: Access to this API has been restricted + at ChildProcess.spawn (node:internal/child_process:388:28) + at Object.spawn (node:child_process:723:9) + at Object. (/home/index.js:3:14) + at Module._compile (node:internal/modules/cjs/loader:1120:14) + at Module._extensions..js (node:internal/modules/cjs/loader:1174:10) + at Module.load (node:internal/modules/cjs/loader:998:32) + at Module._load (node:internal/modules/cjs/loader:839:12) + at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) + at node:internal/main/run_main_module:17:47 { + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess' +} +``` + +### `--allow-fs-read` + + + +> Stability: 1 - Experimental + +This flag configures file system read permissions using +the [Permission Model][]. + +The valid arguments for the `--allow-fs-read` flag are: + +* `*` - To allow the `FileSystemRead` operations. +* Paths delimited by comma (,) to manage `FileSystemRead` (reading) operations. + +Examples can be found in the [File System Permissions][] documentation. + +Relative paths are NOT yet supported by the CLI flag. + +The initializer module also needs to be allowed. Consider the following example: + +```console +$ node --experimental-permission t.js +node:internal/modules/cjs/loader:162 + const result = internalModuleStat(filename); + ^ + +Error: Access to this API has been restricted + at stat (node:internal/modules/cjs/loader:162:18) + at Module._findPath (node:internal/modules/cjs/loader:640:16) + at resolveMainPath (node:internal/modules/run_main:15:25) + at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:53:24) + at node:internal/main/run_main_module:23:47 { + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: '/Users/rafaelgss/repos/os/node/t.js' +} +``` + +The process needs to have access to the `index.js` module: + +```console +$ node --experimental-permission --allow-fs-read=/path/to/index.js index.js +``` + +### `--allow-fs-write` + + + +> Stability: 1 - Experimental + +This flag configures file system write permissions using +the [Permission Model][]. + +The valid arguments for the `--allow-fs-write` flag are: + +* `*` - To allow the `FileSystemWrite` operations. +* Paths delimited by comma (,) to manage `FileSystemWrite` (writing) operations. + +Examples can be found in the [File System Permissions][] documentation. + +Relative paths are NOT supported through the CLI flag. + +### `--allow-worker` + + + +> Stability: 1 - Experimental + +When using the [Permission Model][], the process will not be able to create any +worker threads by default. +For security reasons, the call will throw an `ERR_ACCESS_DENIED` unless the +user explicitly pass the flag `--allow-worker` in the main Node.js process. + +Example: + +```js +const { Worker } = require('node:worker_threads'); +// Attempt to bypass the permission +new Worker(__filename); +``` + +```console +$ node --experimental-permission --allow-fs-read=* index.js +node:internal/worker:188 + this[kHandle] = new WorkerImpl(url, + ^ + +Error: Access to this API has been restricted + at new Worker (node:internal/worker:188:21) + at Object. (/home/index.js.js:3:1) + at Module._compile (node:internal/modules/cjs/loader:1120:14) + at Module._extensions..js (node:internal/modules/cjs/loader:1174:10) + at Module.load (node:internal/modules/cjs/loader:998:32) + at Module._load (node:internal/modules/cjs/loader:839:12) + at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) + at node:internal/main/run_main_module:17:47 { + code: 'ERR_ACCESS_DENIED', + permission: 'WorkerThreads' +} +``` + ### `--build-snapshot` + +Enable the Permission Model for current process. When enabled, the +following permissions are restricted: + +* File System - manageable through + \[`--allow-fs-read`]\[],\[`allow-fs-write`]\[] flags +* Child Process - manageable through \[`--allow-child-process`]\[] flag +* Worker Threads - manageable through \[`--allow-worker`]\[] flag + ### `--experimental-policy` +* `--allow-child-process` +* `--allow-fs-read` +* `--allow-fs-write` +* `--allow-worker` * `--conditions`, `-C` * `--diagnostic-dir` * `--disable-proto` @@ -1896,6 +2062,7 @@ Node.js options that are allowed are: * `--experimental-loader` * `--experimental-modules` * `--experimental-network-imports` +* `--experimental-permission` * `--experimental-policy` * `--experimental-shadow-realm` * `--experimental-specifier-resolution` @@ -2331,9 +2498,11 @@ done [ECMAScript module]: esm.md#modules-ecmascript-modules [ECMAScript module loader]: esm.md#loaders [Fetch API]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API +[File System Permissions]: permissions.md#file-system-permissions [Modules loaders]: packages.md#modules-loaders [Node.js issue tracker]: https://github.com/nodejs/node/issues [OSSL_PROVIDER-legacy]: https://www.openssl.org/docs/man3.0/man7/OSSL_PROVIDER-legacy.html +[Permission Model]: permissions.md#permission-model [REPL]: repl.md [ScriptCoverage]: https://chromedevtools.github.io/devtools-protocol/tot/Profiler#type-ScriptCoverage [ShadowRealm]: https://github.com/tc39/proposal-shadowrealm diff --git a/doc/api/errors.md b/doc/api/errors.md index a895dd5a5af904..9139194719ade5 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -679,6 +679,13 @@ APIs _not_ using `AbortSignal`s typically do not raise an error with this code. This code does not use the regular `ERR_*` convention Node.js errors use in order to be compatible with the web platform's `AbortError`. + + +### `ERR_ACCESS_DENIED` + +A special type of error that is triggered whenever Node.js tries to get access +to a resource restricted by the [Permission Model][]. + ### `ERR_AMBIGUOUS_ARGUMENT` @@ -3542,6 +3549,7 @@ The native call from `process.cpuUsage` could not be processed. [JSON Web Key Elliptic Curve Registry]: https://www.iana.org/assignments/jose/jose.xhtml#web-key-elliptic-curve [JSON Web Key Types Registry]: https://www.iana.org/assignments/jose/jose.xhtml#web-key-types [Node.js error codes]: #nodejs-error-codes +[Permission Model]: permissions.md#permission-model [RFC 7230 Section 3]: https://tools.ietf.org/html/rfc7230#section-3 [Subresource Integrity specification]: https://www.w3.org/TR/SRI/#the-integrity-attribute [V8's stack trace API]: https://v8.dev/docs/stack-trace-api diff --git a/doc/api/permissions.md b/doc/api/permissions.md index a4be1a7c399b4e..b31d2934b3aad2 100644 --- a/doc/api/permissions.md +++ b/doc/api/permissions.md @@ -10,6 +10,12 @@ be accessed by other modules. This can be used to control what modules can be accessed by third-party dependencies, for example. +* [Process-based permissions](#process-based-permissions) control the Node.js + process's access to resources. + The resource can be entirely allowed or denied, or actions related to it can + be controlled. For example, file system reads can be allowed while denying + writes. + If you find a potential security vulnerability, please refer to our [Security Policy][]. @@ -440,7 +446,154 @@ not adopt the origin of the `blob:` URL. Additionally, import maps only work on `import` so it may be desirable to add a `"import"` condition to all dependency mappings. +## Process-based permissions + +### Permission Model + + + +> Stability: 1 - Experimental + + + +The Node.js Permission Model is a mechanism for restricting access to specific +resources during execution. +The API exists behind a flag [`--experimental-permission`][] which when enabled, +will restrict access to all available permissions. + +The available permissions are documented by the [`--experimental-permission`][] +flag. + +When starting Node.js with `--experimental-permission`, +the ability to access the file system, spawn processes, and +use `node:worker_threads` will be restricted. + +```console +$ node --experimental-permission index.js +node:internal/modules/cjs/loader:171 + const result = internalModuleStat(filename); + ^ + +Error: Access to this API has been restricted + at stat (node:internal/modules/cjs/loader:171:18) + at Module._findPath (node:internal/modules/cjs/loader:627:16) + at resolveMainPath (node:internal/modules/run_main:19:25) + at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:76:24) + at node:internal/main/run_main_module:23:47 { + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead' +} +``` + +Allowing access to spawning a process and creating worker threads can be done +using the [`--allow-child-process`][] and [`--allow-worker`][] respectively. + +#### Runtime API + +When enabling the Permission Model through the [`--experimental-permission`][] +flag a new property `permission` is added to the `process` object. +This property contains two functions: + +##### `permission.deny(scope [,parameters])` + +API call to deny permissions at runtime ([`permission.deny()`][]) + +```js +process.permission.deny('fs'); // Deny permissions to ALL fs operations + +// Deny permissions to ALL FileSystemWrite operations +process.permission.deny('fs.write'); +// deny FileSystemWrite permissions to the protected-folder +process.permission.deny('fs.write', ['/home/rafaelgss/protected-folder']); +// Deny permissions to ALL FileSystemRead operations +process.permission.deny('fs.read'); +// deny FileSystemRead permissions to the protected-folder +process.permission.deny('fs.read', ['/home/rafaelgss/protected-folder']); +``` + +##### `permission.has(scope ,parameters)` + +API call to check permissions at runtime ([`permission.has()`][]) + +```js +process.permission.has('fs.write'); // true +process.permission.has('fs.write', '/home/rafaelgss/protected-folder'); // true + +process.permission.deny('fs.write', '/home/rafaelgss/protected-folder'); + +process.permission.has('fs.write'); // true +process.permission.has('fs.write', '/home/rafaelgss/protected-folder'); // false +``` + +#### File System Permissions + +To allow access to the file system, use the [`--allow-fs-read`][] and +[`--allow-fs-write`][] flags: + +```console +$ node --experimental-permission --allow-fs-read=* --allow-fs-write=* index.js +Hello world! +(node:19836) ExperimentalWarning: Permission is an experimental feature +(Use `node --trace-warnings ...` to show where the warning was created) +``` + +The valid arguments for both flags are: + +* `*` - To allow the all operations to given scope (read/write). +* Paths delimited by comma (,) to manage reading/writing operations. + +Example: + +* `--allow-fs-read=*` - It will allow all `FileSystemRead` operations. +* `--allow-fs-write=*` - It will allow all `FileSystemWrite` operations. +* `--allow-fs-write=/tmp/` - It will allow `FileSystemWrite` access to the `/tmp/` + folder. +* `--allow-fs-read=/tmp/,/home/.gitignore` - It allows `FileSystemRead` access + to the `/tmp/` folder **and** the `/home/.gitignore` path. + +Wildcards are supported too: + +* `--allow-fs-read:/home/test*` will allow read access to everything + that matches the wildcard. e.g: `/home/test/file1` or `/home/test2` + +There are constraints you need to know before using this system: + +* Native modules are restricted by default when using the Permission Model. +* Relative paths are not supported through the CLI (`--allow-fs-*`). + The runtime API supports relative paths. +* The model does not inherit to a child node process. +* The model does not inherit to a worker thread. +* When creating symlinks the target (first argument) should have read and + write access. +* Permission changes are not retroactively applied to existing resources. + Consider the following snippet: + ```js + const fs = require('node:fs'); + + // Open a fd + const fd = fs.openSync('./README.md', 'r'); + // Then, deny access to all fs.read operations + process.permission.deny('fs.read'); + // This call will NOT fail and the file will be read + const data = fs.readFileSync(fd); + ``` + +Therefore, when possible, apply the permissions rules before any statement: + +```js +process.permission.deny('fs.read'); +const fd = fs.openSync('./README.md', 'r'); +// Error: Access to this API has been restricted +``` + [Security Policy]: https://github.com/nodejs/node/blob/main/SECURITY.md +[`--allow-child-process`]: cli.md#--allow-child-process +[`--allow-fs-read`]: cli.md#--allow-fs-read +[`--allow-fs-write`]: cli.md#--allow-fs-write +[`--allow-worker`]: cli.md#--allow-worker +[`--experimental-permission`]: cli.md#--experimental-permission +[`permission.deny()`]: process.md#processpermissiondenyscope-reference +[`permission.has()`]: process.md#processpermissionhasscope-reference [import maps]: https://url.spec.whatwg.org/#relative-url-with-fragment-string [relative-url string]: https://url.spec.whatwg.org/#relative-url-with-fragment-string [special schemes]: https://url.spec.whatwg.org/#special-scheme diff --git a/doc/api/process.md b/doc/api/process.md index 33b60f69a288a4..c6537aa44d3349 100644 --- a/doc/api/process.md +++ b/doc/api/process.md @@ -2618,6 +2618,79 @@ the [`'warning'` event][process_warning] and the [`emitWarning()` method][process_emit_warning] for more information about this flag's behavior. +## `process.permission` + + + +* {Object} + +This API is available through the [`--experimental-permission`][] flag. + +`process.permission` is an object whose methods are used to manage permissions +for the current process. Additional documentation is available in the +[Permission Model][]. + +### `process.permission.deny(scope[, reference])` + + + +* `scopes` {string} +* `reference` {Array} +* Returns: {boolean} + +Deny permissions at runtime. + +The available scopes are: + +* `fs` - All File System +* `fs.read` - File System read operations +* `fs.write` - File System write operations + +The reference has a meaning based on the provided scope. For example, +the reference when the scope is File System means files and folders. + +```js +// Deny READ operations to the ./README.md file +process.permission.deny('fs.read', ['./README.md']); +// Deny ALL WRITE operations +process.permission.deny('fs.write'); +``` + +### `process.permission.has(scope[, reference])` + + + +* `scopes` {string} +* `reference` {string} +* Returns: {boolean} + +Verifies that the process is able to access the given scope and reference. +If no reference is provided, a global scope is assumed, for instance, +`process.permission.has('fs.read')` will check if the process has ALL +file system read permissions. + +The reference has a meaning based on the provided scope. For example, +the reference when the scope is File System means files and folders. + +The available scopes are: + +* `fs` - All File System +* `fs.read` - File System read operations +* `fs.write` - File System write operations + +```js +// Check if the process has permission to read the README file +process.permission.has('fs.read', './README.md'); +// Check if the process has read permission operations +process.permission.has('fs.read'); +``` + ## `process.pid` er + // ---> n + // If */slow* is inserted right after, it will create an + // empty node + // /slow + // ---> '\000' ASCII (0) || \0 + // ---> er + // ---> n + bool IsEndNode() { + if (children.size() == 0) { + return true; + } + return children['\0'] != nullptr; + } + }; + + RadixTree(); + ~RadixTree(); + void Insert(const std::string& s); + bool Lookup(const std::string_view& s) { return Lookup(s, false); } + bool Lookup(const std::string_view& s, bool when_empty_return); + + private: + Node* root_node_; + }; + + private: + void GrantAccess(PermissionScope scope, std::string param); + void RestrictAccess(PermissionScope scope, + const std::vector& params); + // /tmp/* --grant + // /tmp/dsadsa/t.js denied in runtime + // + // /tmp/text.txt -- grant + // /tmp/text.txt -- denied in runtime + // + // fs granted on startup + RadixTree granted_in_fs_; + RadixTree granted_out_fs_; + // fs denied in runtime + RadixTree deny_in_fs_; + RadixTree deny_out_fs_; + + bool deny_all_in_ = true; + bool deny_all_out_ = true; + + bool allow_all_in_ = false; + bool allow_all_out_ = false; +}; + +} // namespace permission + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_PERMISSION_FS_PERMISSION_H_ diff --git a/src/permission/permission.cc b/src/permission/permission.cc new file mode 100644 index 00000000000000..c156133e64a13d --- /dev/null +++ b/src/permission/permission.cc @@ -0,0 +1,200 @@ +#include "permission.h" +#include "base_object-inl.h" +#include "env-inl.h" +#include "memory_tracker-inl.h" +#include "node.h" +#include "node_errors.h" +#include "node_external_reference.h" + +#include "v8.h" + +#include +#include +#include + +namespace node { + +using v8::Array; +using v8::Context; +using v8::FunctionCallbackInfo; +using v8::Integer; +using v8::Local; +using v8::Object; +using v8::String; +using v8::Value; + +namespace permission { + +namespace { + +// permission.deny('fs.read', ['/tmp/']) +// permission.deny('fs.read') +static void Deny(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + v8::Isolate* isolate = env->isolate(); + CHECK(args[0]->IsString()); + std::string deny_scope = *String::Utf8Value(isolate, args[0]); + PermissionScope scope = Permission::StringToPermission(deny_scope); + if (scope == PermissionScope::kPermissionsRoot) { + return args.GetReturnValue().Set(false); + } + + std::vector params; + if (args.Length() == 1 || args[1]->IsUndefined()) { + return args.GetReturnValue().Set(env->permission()->Deny(scope, params)); + } + + CHECK(args[1]->IsArray()); + Local js_params = Local::Cast(args[1]); + Local context = isolate->GetCurrentContext(); + + for (uint32_t i = 0; i < js_params->Length(); ++i) { + Local arg; + if (!js_params->Get(context, Integer::New(isolate, i)).ToLocal(&arg)) { + return; + } + String::Utf8Value utf8_arg(isolate, arg); + if (*utf8_arg == nullptr) { + return; + } + params.push_back(*utf8_arg); + } + + return args.GetReturnValue().Set(env->permission()->Deny(scope, params)); +} + +// permission.has('fs.in', '/tmp/') +// permission.has('fs.in') +static void Has(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + v8::Isolate* isolate = env->isolate(); + CHECK(args[0]->IsString()); + + String::Utf8Value utf8_deny_scope(isolate, args[0]); + if (*utf8_deny_scope == nullptr) { + return; + } + + const std::string deny_scope = *utf8_deny_scope; + PermissionScope scope = Permission::StringToPermission(deny_scope); + if (scope == PermissionScope::kPermissionsRoot) { + return args.GetReturnValue().Set(false); + } + + if (args.Length() > 1 && !args[1]->IsUndefined()) { + String::Utf8Value utf8_arg(isolate, args[1]); + if (*utf8_arg == nullptr) { + return; + } + return args.GetReturnValue().Set( + env->permission()->is_granted(scope, *utf8_arg)); + } + + return args.GetReturnValue().Set(env->permission()->is_granted(scope)); +} + +} // namespace + +#define V(Name, label, _) \ + if (perm == PermissionScope::k##Name) return #Name; +const char* Permission::PermissionToString(const PermissionScope perm) { + PERMISSIONS(V) + return nullptr; +} +#undef V + +#define V(Name, label, _) \ + if (perm == label) return PermissionScope::k##Name; +PermissionScope Permission::StringToPermission(const std::string& perm) { + PERMISSIONS(V) + return PermissionScope::kPermissionsRoot; +} +#undef V + +Permission::Permission() : enabled_(false) { + std::shared_ptr fs = std::make_shared(); + std::shared_ptr child_p = + std::make_shared(); + std::shared_ptr worker_t = + std::make_shared(); +#define V(Name, _, __) \ + nodes_.insert(std::make_pair(PermissionScope::k##Name, fs)); + FILESYSTEM_PERMISSIONS(V) +#undef V +#define V(Name, _, __) \ + nodes_.insert(std::make_pair(PermissionScope::k##Name, child_p)); + CHILD_PROCESS_PERMISSIONS(V) +#undef V +#define V(Name, _, __) \ + nodes_.insert(std::make_pair(PermissionScope::k##Name, worker_t)); + WORKER_THREADS_PERMISSIONS(V) +#undef V +} + +void Permission::ThrowAccessDenied(Environment* env, + PermissionScope perm, + const std::string_view& res) { + Local err = ERR_ACCESS_DENIED(env->isolate()); + CHECK(err->IsObject()); + err.As() + ->Set(env->context(), + env->permission_string(), + v8::String::NewFromUtf8(env->isolate(), + PermissionToString(perm), + v8::NewStringType::kNormal) + .ToLocalChecked()) + .FromMaybe(false); + err.As() + ->Set(env->context(), + env->resource_string(), + v8::String::NewFromUtf8(env->isolate(), + std::string(res).c_str(), + v8::NewStringType::kNormal) + .ToLocalChecked()) + .FromMaybe(false); + env->isolate()->ThrowException(err); +} + +void Permission::EnablePermissions() { + if (!enabled_) { + enabled_ = true; + } +} + +void Permission::Apply(const std::string& allow, PermissionScope scope) { + auto permission = nodes_.find(scope); + if (permission != nodes_.end()) { + permission->second->Apply(allow, scope); + } +} + +bool Permission::Deny(PermissionScope scope, + const std::vector& params) { + auto permission = nodes_.find(scope); + if (permission != nodes_.end()) { + return permission->second->Deny(scope, params); + } + return false; +} + +void Initialize(Local target, + Local unused, + Local context, + void* priv) { + SetMethod(context, target, "deny", Deny); + SetMethodNoSideEffect(context, target, "has", Has); + + target->SetIntegrityLevel(context, v8::IntegrityLevel::kFrozen).FromJust(); +} + +void RegisterExternalReferences(ExternalReferenceRegistry* registry) { + registry->Register(Deny); + registry->Register(Has); +} + +} // namespace permission +} // namespace node + +NODE_BINDING_CONTEXT_AWARE_INTERNAL(permission, node::permission::Initialize) +NODE_BINDING_EXTERNAL_REFERENCE(permission, + node::permission::RegisterExternalReferences) diff --git a/src/permission/permission.h b/src/permission/permission.h new file mode 100644 index 00000000000000..f5b6f4cba9e3bb --- /dev/null +++ b/src/permission/permission.h @@ -0,0 +1,73 @@ +#ifndef SRC_PERMISSION_PERMISSION_H_ +#define SRC_PERMISSION_PERMISSION_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include "debug_utils.h" +#include "node_options.h" +#include "permission/child_process_permission.h" +#include "permission/fs_permission.h" +#include "permission/permission_base.h" +#include "permission/worker_permission.h" +#include "v8.h" + +#include +#include + +namespace node { + +class Environment; + +namespace permission { + +#define THROW_IF_INSUFFICIENT_PERMISSIONS(env, perm_, resource_, ...) \ + do { \ + if (UNLIKELY(!(env)->permission()->is_granted(perm_, resource_))) { \ + node::permission::Permission::ThrowAccessDenied( \ + (env), perm_, resource_); \ + return __VA_ARGS__; \ + } \ + } while (0) + +class Permission { + public: + Permission(); + + FORCE_INLINE bool is_granted(const PermissionScope permission, + const std::string_view& res = "") const { + if (LIKELY(!enabled_)) return true; + return is_scope_granted(permission, res); + } + + static PermissionScope StringToPermission(const std::string& perm); + static const char* PermissionToString(PermissionScope perm); + static void ThrowAccessDenied(Environment* env, + PermissionScope perm, + const std::string_view& res); + + // CLI Call + void Apply(const std::string& deny, PermissionScope scope); + // Permission.Deny API + bool Deny(PermissionScope scope, const std::vector& params); + void EnablePermissions(); + + private: + COLD_NOINLINE bool is_scope_granted(const PermissionScope permission, + const std::string_view& res = "") const { + auto perm_node = nodes_.find(permission); + if (perm_node != nodes_.end()) { + return perm_node->second->is_granted(permission, res); + } + return false; + } + + std::unordered_map> nodes_; + bool enabled_; +}; + +} // namespace permission + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_PERMISSION_PERMISSION_H_ diff --git a/src/permission/permission_base.h b/src/permission/permission_base.h new file mode 100644 index 00000000000000..4240db17cd4938 --- /dev/null +++ b/src/permission/permission_base.h @@ -0,0 +1,51 @@ +#ifndef SRC_PERMISSION_PERMISSION_BASE_H_ +#define SRC_PERMISSION_PERMISSION_BASE_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include +#include +#include +#include "v8.h" + +namespace node { + +namespace permission { + +#define FILESYSTEM_PERMISSIONS(V) \ + V(FileSystem, "fs", PermissionsRoot) \ + V(FileSystemRead, "fs.read", FileSystem) \ + V(FileSystemWrite, "fs.write", FileSystem) + +#define CHILD_PROCESS_PERMISSIONS(V) V(ChildProcess, "child", PermissionsRoot) + +#define WORKER_THREADS_PERMISSIONS(V) \ + V(WorkerThreads, "worker", PermissionsRoot) + +#define PERMISSIONS(V) \ + FILESYSTEM_PERMISSIONS(V) \ + CHILD_PROCESS_PERMISSIONS(V) \ + WORKER_THREADS_PERMISSIONS(V) + +#define V(name, _, __) k##name, +enum class PermissionScope { + kPermissionsRoot = -1, + PERMISSIONS(V) kPermissionsCount +}; +#undef V + +class PermissionBase { + public: + virtual void Apply(const std::string& deny, PermissionScope scope) = 0; + virtual bool Deny(PermissionScope scope, + const std::vector& params) = 0; + virtual bool is_granted(PermissionScope perm, + const std::string_view& param = "") = 0; +}; + +} // namespace permission + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_PERMISSION_PERMISSION_BASE_H_ diff --git a/src/permission/worker_permission.cc b/src/permission/worker_permission.cc new file mode 100644 index 00000000000000..2de48e1ba728eb --- /dev/null +++ b/src/permission/worker_permission.cc @@ -0,0 +1,26 @@ +#include "permission/worker_permission.h" + +#include +#include + +namespace node { + +namespace permission { + +// Currently, PolicyDenyWorker manage a single state +// Once denied, it's always denied +void WorkerPermission::Apply(const std::string& deny, PermissionScope scope) {} + +bool WorkerPermission::Deny(PermissionScope perm, + const std::vector& params) { + deny_all_ = true; + return true; +} + +bool WorkerPermission::is_granted(PermissionScope perm, + const std::string_view& param) { + return deny_all_ == false; +} + +} // namespace permission +} // namespace node diff --git a/src/permission/worker_permission.h b/src/permission/worker_permission.h new file mode 100644 index 00000000000000..1a93e2253d07da --- /dev/null +++ b/src/permission/worker_permission.h @@ -0,0 +1,30 @@ +#ifndef SRC_PERMISSION_WORKER_PERMISSION_H_ +#define SRC_PERMISSION_WORKER_PERMISSION_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include +#include "permission/permission_base.h" + +namespace node { + +namespace permission { + +class WorkerPermission final : public PermissionBase { + public: + void Apply(const std::string& deny, PermissionScope scope) override; + bool Deny(PermissionScope scope, + const std::vector& params) override; + bool is_granted(PermissionScope perm, + const std::string_view& param = "") override; + + private: + bool deny_all_; +}; + +} // namespace permission + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_PERMISSION_WORKER_PERMISSION_H_ diff --git a/src/process_wrap.cc b/src/process_wrap.cc index ffa5dbd1306a6d..42a746308ba8ac 100644 --- a/src/process_wrap.cc +++ b/src/process_wrap.cc @@ -20,6 +20,7 @@ // USE OR OTHER DEALINGS IN THE SOFTWARE. #include "env-inl.h" +#include "permission/permission.h" #include "stream_base-inl.h" #include "stream_wrap.h" #include "util-inl.h" @@ -147,6 +148,8 @@ class ProcessWrap : public HandleWrap { Local context = env->context(); ProcessWrap* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, args.Holder()); + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kChildProcess, ""); Local js_options = args[0]->ToObject(env->context()).ToLocalChecked(); diff --git a/src/util.h b/src/util.h index 1d3c480b234c4c..b96738b849a320 100644 --- a/src/util.h +++ b/src/util.h @@ -538,6 +538,11 @@ class Utf8Value : public MaybeStackBuffer { public: explicit Utf8Value(v8::Isolate* isolate, v8::Local value); + inline std::string ToString() const { return std::string(out(), length()); } + inline std::string_view ToStringView() const { + return std::string_view(out(), length()); + } + inline bool operator==(const char* a) const { return strcmp(out(), a) == 0; } @@ -553,6 +558,9 @@ class BufferValue : public MaybeStackBuffer { explicit BufferValue(v8::Isolate* isolate, v8::Local value); inline std::string ToString() const { return std::string(out(), length()); } + inline std::string_view ToStringView() const { + return std::string_view(out(), length()); + } }; #define SPREAD_BUFFER_ARG(val, name) \ diff --git a/test/addons/no-addons/permission.js b/test/addons/no-addons/permission.js new file mode 100644 index 00000000000000..0fbcd2bb1ee782 --- /dev/null +++ b/test/addons/no-addons/permission.js @@ -0,0 +1,43 @@ +// Flags: --experimental-permission --allow-fs-read=* + +'use strict'; + +const common = require('../../common'); +const assert = require('assert'); + +const bindingPath = require.resolve(`./build/${common.buildType}/binding`); + +const assertError = (error) => { + assert(error instanceof Error); + assert.strictEqual(error.code, 'ERR_DLOPEN_DISABLED'); + assert.strictEqual( + error.message, + 'Cannot load native addon because loading addons is disabled.', + ); +}; + +{ + let threw = false; + + try { + require(bindingPath); + } catch (error) { + assertError(error); + threw = true; + } + + assert(threw); +} + +{ + let threw = false; + + try { + process.dlopen({ exports: {} }, bindingPath); + } catch (error) { + assertError(error); + threw = true; + } + + assert(threw); +} diff --git a/test/common/README.md b/test/common/README.md index 4d26e355d5480f..061c1e662746ea 100644 --- a/test/common/README.md +++ b/test/common/README.md @@ -1005,9 +1005,12 @@ The `tmpdir` module supports the use of a temporary directory for testing. The realpath of the testing temporary directory. -### `refresh()` +### `refresh(useSpawn)` -Deletes and recreates the testing temporary directory. +* `useSpawn` [\][] default = false + +Deletes and recreates the testing temporary directory. When `useSpawn` is true +this action is performed using `child_process.spawnSync`. The first time `refresh()` runs, it adds a listener to process `'exit'` that cleans the temporary directory. Thus, every file under `tmpdir.path` needs to diff --git a/test/common/tmpdir.js b/test/common/tmpdir.js index 1f2ebb18ee64e0..3c4ca546d062d3 100644 --- a/test/common/tmpdir.js +++ b/test/common/tmpdir.js @@ -1,11 +1,23 @@ 'use strict'; +const { spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const { isMainThread } = require('worker_threads'); -function rmSync(pathname) { - fs.rmSync(pathname, { maxRetries: 3, recursive: true, force: true }); +function rmSync(pathname, useSpawn) { + if (useSpawn) { + const escapedPath = pathname.replaceAll('\\', '\\\\'); + spawnSync( + process.execPath, + [ + '-e', + `require("fs").rmSync("${escapedPath}", { maxRetries: 3, recursive: true, force: true });`, + ], + ); + } else { + fs.rmSync(pathname, { maxRetries: 3, recursive: true, force: true }); + } } const testRoot = process.env.NODE_TEST_DIR ? @@ -18,25 +30,27 @@ const tmpdirName = '.tmp.' + const tmpPath = path.join(testRoot, tmpdirName); let firstRefresh = true; -function refresh() { - rmSync(tmpPath); +function refresh(useSpawn = false) { + rmSync(tmpPath, useSpawn); fs.mkdirSync(tmpPath); if (firstRefresh) { firstRefresh = false; // Clean only when a test uses refresh. This allows for child processes to // use the tmpdir and only the parent will clean on exit. - process.on('exit', onexit); + process.on('exit', () => { + return onexit(useSpawn); + }); } } -function onexit() { +function onexit(useSpawn) { // Change directory to avoid possible EBUSY if (isMainThread) process.chdir(testRoot); try { - rmSync(tmpPath); + rmSync(tmpPath, useSpawn); } catch (e) { console.error('Can\'t clean tmpdir:', tmpPath); diff --git a/test/fixtures/permission/deny/protected-file.md b/test/fixtures/permission/deny/protected-file.md new file mode 100644 index 00000000000000..845763d2403ff4 --- /dev/null +++ b/test/fixtures/permission/deny/protected-file.md @@ -0,0 +1,3 @@ +# Protected File + +Example of a protected file to be used in the PolicyDenyFs module diff --git a/test/fixtures/permission/deny/protected-folder/protected-file.md b/test/fixtures/permission/deny/protected-folder/protected-file.md new file mode 100644 index 00000000000000..845763d2403ff4 --- /dev/null +++ b/test/fixtures/permission/deny/protected-folder/protected-file.md @@ -0,0 +1,3 @@ +# Protected File + +Example of a protected file to be used in the PolicyDenyFs module diff --git a/test/fixtures/permission/deny/regular-file.md b/test/fixtures/permission/deny/regular-file.md new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js index c0fd99055de445..6efb806e205009 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -49,6 +49,7 @@ const expectedModules = new Set([ 'NativeModule internal/constants', 'NativeModule path', 'NativeModule internal/process/execution', + 'NativeModule internal/process/permission', 'NativeModule internal/process/warning', 'NativeModule internal/console/constructor', 'NativeModule internal/console/global', @@ -59,6 +60,7 @@ const expectedModules = new Set([ 'NativeModule internal/url', 'NativeModule util', 'Internal Binding performance', + 'Internal Binding permission', 'NativeModule internal/perf/utils', 'NativeModule internal/event_target', 'Internal Binding mksnapshot', diff --git a/test/parallel/test-cli-bad-options.js b/test/parallel/test-cli-bad-options.js index 1bdaf5ee939b9f..8a77e94babb4fa 100644 --- a/test/parallel/test-cli-bad-options.js +++ b/test/parallel/test-cli-bad-options.js @@ -14,6 +14,17 @@ if (process.features.inspector) { } requiresArgument('--eval'); +missingOption('--allow-fs-read=*', '--experimental-permission'); +missingOption('--allow-fs-write=*', '--experimental-permission'); + +function missingOption(option, requiredOption) { + const r = spawnSync(process.execPath, [option], { encoding: 'utf8' }); + assert.strictEqual(r.status, 1); + + const message = `${requiredOption} is required`; + assert.match(r.stderr, new RegExp(message)); +} + function requiresArgument(option) { const r = spawnSync(process.execPath, [option], { encoding: 'utf8' }); diff --git a/test/parallel/test-cli-permission-deny-fs.js b/test/parallel/test-cli-permission-deny-fs.js new file mode 100644 index 00000000000000..6af6ba40788175 --- /dev/null +++ b/test/parallel/test-cli-permission-deny-fs.js @@ -0,0 +1,128 @@ +'use strict'; + +require('../common'); +const { spawnSync } = require('child_process'); +const assert = require('assert'); +const fs = require('fs'); + +{ + const { status, stdout } = spawnSync( + process.execPath, + [ + '--experimental-permission', '-e', + `console.log(process.permission.has("fs")); + console.log(process.permission.has("fs.read")); + console.log(process.permission.has("fs.write"));`, + ] + ); + + const [fs, fsIn, fsOut] = stdout.toString().split('\n'); + assert.strictEqual(fs, 'false'); + assert.strictEqual(fsIn, 'false'); + assert.strictEqual(fsOut, 'false'); + assert.strictEqual(status, 0); +} + +{ + const { status, stdout } = spawnSync( + process.execPath, + [ + '--experimental-permission', + '--allow-fs-write', '/tmp/', '-e', + `console.log(process.permission.has("fs")); + console.log(process.permission.has("fs.read")); + console.log(process.permission.has("fs.write")); + console.log(process.permission.has("fs.write", "/tmp/"));`, + ] + ); + const [fs, fsIn, fsOut, fsOutAllowed] = stdout.toString().split('\n'); + assert.strictEqual(fs, 'false'); + assert.strictEqual(fsIn, 'false'); + assert.strictEqual(fsOut, 'false'); + assert.strictEqual(fsOutAllowed, 'true'); + assert.strictEqual(status, 0); +} + +{ + const { status, stdout } = spawnSync( + process.execPath, + [ + '--experimental-permission', + '--allow-fs-write', '*', '-e', + `console.log(process.permission.has("fs")); + console.log(process.permission.has("fs.read")); + console.log(process.permission.has("fs.write"));`, + ] + ); + + const [fs, fsIn, fsOut] = stdout.toString().split('\n'); + assert.strictEqual(fs, 'false'); + assert.strictEqual(fsIn, 'false'); + assert.strictEqual(fsOut, 'true'); + assert.strictEqual(status, 0); +} + +{ + const { status, stdout } = spawnSync( + process.execPath, + [ + '--experimental-permission', + '--allow-fs-read', '*', '-e', + `console.log(process.permission.has("fs")); + console.log(process.permission.has("fs.read")); + console.log(process.permission.has("fs.write"));`, + ] + ); + + const [fs, fsIn, fsOut] = stdout.toString().split('\n'); + assert.strictEqual(fs, 'false'); + assert.strictEqual(fsIn, 'true'); + assert.strictEqual(fsOut, 'false'); + assert.strictEqual(status, 0); +} + +{ + const { status, stderr } = spawnSync( + process.execPath, + [ + '--experimental-permission', + '--allow-fs-write=*', '-p', + 'fs.readFileSync(process.execPath)', + ] + ); + assert.ok( + stderr.toString().includes('Access to this API has been restricted'), + stderr); + assert.strictEqual(status, 1); +} + +{ + const { status, stderr } = spawnSync( + process.execPath, + [ + '--experimental-permission', + '-p', + 'fs.readFileSync(process.execPath)', + ] + ); + assert.ok( + stderr.toString().includes('Access to this API has been restricted'), + stderr); + assert.strictEqual(status, 1); +} + +{ + const { status, stderr } = spawnSync( + process.execPath, + [ + '--experimental-permission', + '--allow-fs-read=*', '-p', + 'fs.writeFileSync("policy-deny-example.md", "# test")', + ] + ); + assert.ok( + stderr.toString().includes('Access to this API has been restricted'), + stderr); + assert.strictEqual(status, 1); + assert.ok(!fs.existsSync('permission-deny-example.md')); +} diff --git a/test/parallel/test-permission-deny-allow-child-process-cli.js b/test/parallel/test-permission-deny-allow-child-process-cli.js new file mode 100644 index 00000000000000..6cffc19719350b --- /dev/null +++ b/test/parallel/test-permission-deny-allow-child-process-cli.js @@ -0,0 +1,26 @@ +// Flags: --experimental-permission --allow-child-process --allow-fs-read=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); +const assert = require('assert'); +const childProcess = require('child_process'); + +if (process.argv[2] === 'child') { + process.exit(0); +} + +// Guarantee the initial state +{ + assert.ok(process.permission.has('child')); +} + +// When a permission is set by cli, the process shouldn't be able +// to spawn unless --allow-child-process is sent +{ + // doesNotThrow + childProcess.spawnSync(process.execPath, ['--version']); + childProcess.execSync(process.execPath, ['--version']); + childProcess.fork(__filename, ['child']); + childProcess.execFileSync(process.execPath, ['--version']); +} diff --git a/test/parallel/test-permission-deny-allow-worker-cli.js b/test/parallel/test-permission-deny-allow-worker-cli.js new file mode 100644 index 00000000000000..ae5a28fdae3597 --- /dev/null +++ b/test/parallel/test-permission-deny-allow-worker-cli.js @@ -0,0 +1,22 @@ +// Flags: --experimental-permission --allow-worker --allow-fs-read=* +'use strict'; + +require('../common'); +const assert = require('assert'); +const { isMainThread, Worker } = require('worker_threads'); + +if (!isMainThread) { + process.exit(0); +} + +// Guarantee the initial state +{ + assert.ok(process.permission.has('worker')); +} + +// When a permission is set by cli, the process shouldn't be able +// to spawn unless --allow-worker is sent +{ + // doesNotThrow + new Worker(__filename).on('exit', (code) => assert.strictEqual(code, 0)); +} diff --git a/test/parallel/test-permission-deny-child-process-cli.js b/test/parallel/test-permission-deny-child-process-cli.js new file mode 100644 index 00000000000000..7f15cacd0d2a3a --- /dev/null +++ b/test/parallel/test-permission-deny-child-process-cli.js @@ -0,0 +1,45 @@ +// Flags: --experimental-permission --allow-fs-read=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); +const assert = require('assert'); +const childProcess = require('child_process'); + +if (process.argv[2] === 'child') { + process.exit(0); +} + +// Guarantee the initial state +{ + assert.ok(!process.permission.has('child')); +} + +// When a permission is set by cli, the process shouldn't be able +// to spawn +{ + assert.throws(() => { + childProcess.spawn(process.execPath, ['--version']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); + assert.throws(() => { + childProcess.exec(process.execPath, ['--version']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); + assert.throws(() => { + childProcess.fork(__filename, ['child']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); + assert.throws(() => { + childProcess.execFile(process.execPath, ['--version']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); +} diff --git a/test/parallel/test-permission-deny-child-process.js b/test/parallel/test-permission-deny-child-process.js new file mode 100644 index 00000000000000..36c0e9da86fc1f --- /dev/null +++ b/test/parallel/test-permission-deny-child-process.js @@ -0,0 +1,52 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-child-process +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); +const assert = require('assert'); +const childProcess = require('child_process'); + +if (process.argv[2] === 'child') { + process.exit(0); +} + +{ + // doesNotThrow + const spawn = childProcess.spawn(process.execPath, ['--version']); + spawn.kill(); + const exec = childProcess.exec(process.execPath, ['--version']); + exec.kill(); + const fork = childProcess.fork(__filename, ['child']); + fork.kill(); + const execFile = childProcess.execFile(process.execPath, ['--version']); + execFile.kill(); + + assert.ok(process.permission.deny('child')); + + // When a permission is set by API, the process shouldn't be able + // to spawn + assert.throws(() => { + childProcess.spawn(process.execPath, ['--version']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); + assert.throws(() => { + childProcess.exec(process.execPath, ['--version']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); + assert.throws(() => { + childProcess.fork(__filename, ['child']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); + assert.throws(() => { + childProcess.execFile(process.execPath, ['--version']); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'ChildProcess', + })); +} diff --git a/test/parallel/test-permission-deny-fs-read.js b/test/parallel/test-permission-deny-fs-read.js new file mode 100644 index 00000000000000..0f918acffd771d --- /dev/null +++ b/test/parallel/test-permission-deny-fs-read.js @@ -0,0 +1,328 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-fs-write=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); + +const assert = require('assert'); +const fs = require('fs'); +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const path = require('path'); +const os = require('os'); + +const blockedFile = fixtures.path('permission', 'deny', 'protected-file.md'); +const relativeProtectedFile = './test/fixtures/permission/deny/protected-file.md'; +const absoluteProtectedFile = path.resolve(relativeProtectedFile); +const blockedFolder = tmpdir.path; +const regularFile = __filename; +const uid = os.userInfo().uid; +const gid = os.userInfo().gid; + +{ + tmpdir.refresh(); + assert.ok(process.permission.deny('fs.read', [blockedFile, blockedFolder])); +} + +// fs.readFile +{ + assert.throws(() => { + fs.readFile(blockedFile, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.readFile(relativeProtectedFile, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + assert.throws(() => { + fs.readFile(path.join(blockedFolder, 'anyfile'), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(path.join(blockedFolder, 'anyfile')), + })); + + // doesNotThrow + fs.readFile(regularFile, () => {}); +} + +// fs.createReadStream +{ + assert.rejects(() => { + return new Promise((_resolve, reject) => { + const stream = fs.createReadStream(blockedFile); + stream.on('error', reject); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })).then(common.mustCall()); + + assert.rejects(() => { + return new Promise((_resolve, reject) => { + const stream = fs.createReadStream(relativeProtectedFile); + stream.on('error', reject); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })).then(common.mustCall()); + + assert.rejects(() => { + return new Promise((_resolve, reject) => { + const stream = fs.createReadStream(blockedFile); + stream.on('error', reject); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })).then(common.mustCall()); +} + +// fs.stat +{ + assert.throws(() => { + fs.stat(blockedFile, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.stat(relativeProtectedFile, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + assert.throws(() => { + fs.stat(path.join(blockedFolder, 'anyfile'), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(path.join(blockedFolder, 'anyfile')), + })); + + // doesNotThrow + fs.stat(regularFile, (err) => { + assert.ifError(err); + }); +} + +// fs.access +{ + assert.throws(() => { + fs.access(blockedFile, fs.constants.R_OK, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.access(relativeProtectedFile, fs.constants.R_OK, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + assert.throws(() => { + fs.access(path.join(blockedFolder, 'anyfile'), fs.constants.R_OK, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(path.join(blockedFolder, 'anyfile')), + })); + + // doesNotThrow + fs.access(regularFile, fs.constants.R_OK, (err) => { + assert.ifError(err); + }); +} + +// fs.chownSync (should not bypass) +{ + assert.throws(() => { + // This operation will work fine + fs.chownSync(blockedFile, uid, gid); + fs.readFileSync(blockedFile); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + // This operation will work fine + fs.chownSync(relativeProtectedFile, uid, gid); + fs.readFileSync(relativeProtectedFile); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); +} + +// fs.copyFile +{ + assert.throws(() => { + fs.copyFile(blockedFile, path.join(blockedFolder, 'any-other-file'), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.copyFile(relativeProtectedFile, path.join(blockedFolder, 'any-other-file'), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + assert.throws(() => { + fs.copyFile(blockedFile, path.join(__dirname, 'any-other-file'), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); +} + +// fs.cp +{ + assert.throws(() => { + fs.cpSync(blockedFile, path.join(blockedFolder, 'any-other-file')); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + // cpSync calls statSync before reading blockedFile + resource: path.toNamespacedPath(blockedFolder), + })); + assert.throws(() => { + fs.cpSync(relativeProtectedFile, path.join(blockedFolder, 'any-other-file')); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFolder), + })); + assert.throws(() => { + fs.cpSync(blockedFile, path.join(__dirname, 'any-other-file')); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); +} + +// fs.open +{ + assert.throws(() => { + fs.open(blockedFile, 'r', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.open(relativeProtectedFile, 'r', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + assert.throws(() => { + fs.open(path.join(blockedFolder, 'anyfile'), 'r', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(path.join(blockedFolder, 'anyfile')), + })); + + // doesNotThrow + fs.open(regularFile, 'r', (err) => { + assert.ifError(err); + }); +} + +// fs.opendir +{ + assert.throws(() => { + fs.opendir(blockedFolder, (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFolder), + })); + // doesNotThrow + fs.opendir(__dirname, (err, dir) => { + assert.ifError(err); + dir.closeSync(); + }); +} + +// fs.readdir +{ + assert.throws(() => { + fs.readdir(blockedFolder, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFolder), + })); + + // doesNotThrow + fs.readdir(__dirname, (err) => { + assert.ifError(err); + }); +} + +// fs.watch +{ + assert.throws(() => { + fs.watch(blockedFile, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.watch(relativeProtectedFile, () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + + // doesNotThrow + fs.readdir(__dirname, (err) => { + assert.ifError(err); + }); +} + +// fs.rename +{ + assert.throws(() => { + fs.rename(blockedFile, 'newfile', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.rename(relativeProtectedFile, 'newfile', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); +} +tmpdir.refresh(); diff --git a/test/parallel/test-permission-deny-fs-symlink-target-write.js b/test/parallel/test-permission-deny-fs-symlink-target-write.js new file mode 100644 index 00000000000000..4d508a2d525cea --- /dev/null +++ b/test/parallel/test-permission-deny-fs-symlink-target-write.js @@ -0,0 +1,71 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-fs-write=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); +if (!common.canCreateSymLink()) + common.skip('insufficient privileges'); + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(true); + +const readOnlyFolder = path.join(tmpdir.path, 'read-only'); +const readWriteFolder = path.join(tmpdir.path, 'read-write'); +const writeOnlyFolder = path.join(tmpdir.path, 'write-only'); + +fs.mkdirSync(readOnlyFolder); +fs.mkdirSync(readWriteFolder); +fs.mkdirSync(writeOnlyFolder); +fs.writeFileSync(path.join(readOnlyFolder, 'file'), 'evil file contents'); +fs.writeFileSync(path.join(readWriteFolder, 'file'), 'NO evil file contents'); + +{ + assert.ok(process.permission.deny('fs.write', [readOnlyFolder])); + assert.ok(process.permission.deny('fs.read', [writeOnlyFolder])); +} + +{ + // App won't be able to symlink from a readOnlyFolder + assert.throws(() => { + fs.symlink(path.join(readOnlyFolder, 'file'), path.join(readWriteFolder, 'link-to-read-only'), 'file', (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(readOnlyFolder, 'file')), + })); + + // App will be able to symlink to a writeOnlyFolder + fs.symlink(path.join(readWriteFolder, 'file'), path.join(writeOnlyFolder, 'link-to-read-write'), 'file', (err) => { + assert.ifError(err); + // App will won't be able to read the symlink + assert.throws(() => { + fs.readFile(path.join(writeOnlyFolder, 'link-to-read-write'), (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + + // App will be able to write to the symlink + fs.writeFile('file', 'some content', (err) => { + assert.ifError(err); + }); + }); + + // App won't be able to symlink to a readOnlyFolder + assert.throws(() => { + fs.symlink(path.join(readWriteFolder, 'file'), path.join(readOnlyFolder, 'link-to-read-only'), 'file', (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(readOnlyFolder, 'link-to-read-only')), + })); +} diff --git a/test/parallel/test-permission-deny-fs-symlink.js b/test/parallel/test-permission-deny-fs-symlink.js new file mode 100644 index 00000000000000..c093800519406e --- /dev/null +++ b/test/parallel/test-permission-deny-fs-symlink.js @@ -0,0 +1,104 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-fs-write=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); +const fixtures = require('../common/fixtures'); +if (!common.canCreateSymLink()) + common.skip('insufficient privileges'); + +const assert = require('assert'); +const fs = require('fs'); + +const path = require('path'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(true); + +const blockedFile = fixtures.path('permission', 'deny', 'protected-file.md'); +const blockedFolder = path.join(tmpdir.path, 'subdirectory'); +const regularFile = __filename; +const symlinkFromBlockedFile = path.join(tmpdir.path, 'example-symlink.md'); + +fs.mkdirSync(blockedFolder); + +{ + // Symlink previously created + fs.symlinkSync(blockedFile, symlinkFromBlockedFile); + assert.ok(process.permission.deny('fs.read', [blockedFile, blockedFolder])); + assert.ok(process.permission.deny('fs.write', [blockedFile, blockedFolder])); +} + +{ + // Previously created symlink are NOT affected by the permission model + const linkData = fs.readlinkSync(symlinkFromBlockedFile); + assert.ok(linkData); + const fileData = fs.readFileSync(symlinkFromBlockedFile); + assert.ok(fileData); + // cleanup + fs.unlink(symlinkFromBlockedFile, (err) => { + assert.ifError( + err, + `Error while removing the symlink: ${symlinkFromBlockedFile}. + You may need to remove it manually to re-run the tests` + ); + }); +} + +{ + // App doesn’t have access to the BLOCKFOLDER + assert.throws(() => { + fs.opendir(blockedFolder, (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.writeFile(blockedFolder + '/new-file', 'data', (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + })); + + // App doesn’t have access to the BLOCKEDFILE folder + assert.throws(() => { + fs.readFile(blockedFile, (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.appendFile(blockedFile, 'data', (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + })); + + // App won't be able to symlink REGULARFILE to BLOCKFOLDER/asdf + assert.throws(() => { + fs.symlink(regularFile, blockedFolder + '/asdf', 'file', (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + })); + + // App won't be able to symlink BLOCKEDFILE to REGULARDIR + assert.throws(() => { + fs.symlink(blockedFile, path.join(__dirname, '/asdf'), 'file', (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); +} +tmpdir.refresh(true); diff --git a/test/parallel/test-permission-deny-fs-wildcard.js b/test/parallel/test-permission-deny-fs-wildcard.js new file mode 100644 index 00000000000000..2e278cb60bddc7 --- /dev/null +++ b/test/parallel/test-permission-deny-fs-wildcard.js @@ -0,0 +1,128 @@ +// Flags: --experimental-permission --allow-fs-read=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); + +const assert = require('assert'); +const fs = require('fs'); + +if (common.isWindows) { + const denyList = [ + 'C:\\tmp\\*', + 'C:\\example\\foo*', + 'C:\\example\\bar*', + 'C:\\folder\\*', + 'C:\\show', + 'C:\\slower', + 'C:\\slown', + 'C:\\home\\foo\\*', + ]; + assert.ok(process.permission.deny('fs.read', denyList)); + assert.ok(process.permission.has('fs.read', 'C:\\slow')); + assert.ok(process.permission.has('fs.read', 'C:\\slows')); + assert.ok(!process.permission.has('fs.read', 'C:\\slown')); + assert.ok(!process.permission.has('fs.read', 'C:\\home\\foo')); + assert.ok(!process.permission.has('fs.read', 'C:\\home\\foo\\')); + assert.ok(process.permission.has('fs.read', 'C:\\home\\fo')); +} else { + const denyList = [ + '/tmp/*', + '/example/foo*', + '/example/bar*', + '/folder/*', + '/show', + '/slower', + '/slown', + '/home/foo/*', + ]; + assert.ok(process.permission.deny('fs.read', denyList)); + assert.ok(process.permission.has('fs.read', '/slow')); + assert.ok(process.permission.has('fs.read', '/slows')); + assert.ok(!process.permission.has('fs.read', '/slown')); + assert.ok(!process.permission.has('fs.read', '/home/foo')); + assert.ok(!process.permission.has('fs.read', '/home/foo/')); + assert.ok(process.permission.has('fs.read', '/home/fo')); +} + +{ + assert.throws(() => { + fs.readFile('/tmp/foo/file', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + // doesNotThrow + fs.readFile('/test.txt', () => {}); + fs.readFile('/tmpd', () => {}); +} + +{ + assert.throws(() => { + fs.readFile('/example/foo/file', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.readFile('/example/foo2/file', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.readFile('/example/foo2', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + + // doesNotThrow + fs.readFile('/example/fo/foo2.js', () => {}); + fs.readFile('/example/for', () => {}); +} + +{ + assert.throws(() => { + fs.readFile('/example/bar/file', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.readFile('/example/bar2/file', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.readFile('/example/bar', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + + // doesNotThrow + fs.readFile('/example/ba/foo2.js', () => {}); +} + +{ + assert.throws(() => { + fs.readFile('/folder/a/subfolder/b', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.readFile('/folder/a/subfolder/b/c.txt', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); + assert.throws(() => { + fs.readFile('/folder/a/foo2.js', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })); +} diff --git a/test/parallel/test-permission-deny-fs-write.js b/test/parallel/test-permission-deny-fs-write.js new file mode 100644 index 00000000000000..1f0d2997be6e31 --- /dev/null +++ b/test/parallel/test-permission-deny-fs-write.js @@ -0,0 +1,240 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-fs-write=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const fixtures = require('../common/fixtures'); + +const blockedFolder = fixtures.path('permission', 'deny', 'protected-folder'); +const blockedFile = fixtures.path('permission', 'deny', 'protected-file.md'); +const relativeProtectedFile = './test/fixtures/permission/deny/protected-file.md'; +const relativeProtectedFolder = './test/fixtures/permission/deny/protected-folder'; +const absoluteProtectedFile = path.resolve(relativeProtectedFile); +const absoluteProtectedFolder = path.resolve(relativeProtectedFolder); + +const regularFolder = fixtures.path('permission', 'deny'); +const regularFile = fixtures.path('permission', 'deny', 'regular-file.md'); + +{ + assert.ok(process.permission.deny('fs.write', [blockedFolder])); + assert.ok(process.permission.deny('fs.write', [blockedFile])); +} + +// fs.writeFile +{ + assert.throws(() => { + fs.writeFile(blockedFile, 'example', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.writeFile(relativeProtectedFile, 'example', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + + assert.throws(() => { + fs.writeFile(path.join(blockedFolder, 'anyfile'), 'example', () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(blockedFolder, 'anyfile')), + })); +} + +// fs.createWriteStream +{ + assert.rejects(() => { + return new Promise((_resolve, reject) => { + const stream = fs.createWriteStream(blockedFile); + stream.on('error', reject); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(blockedFile), + })).then(common.mustCall()); + assert.rejects(() => { + return new Promise((_resolve, reject) => { + const stream = fs.createWriteStream(relativeProtectedFile); + stream.on('error', reject); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(absoluteProtectedFile), + })).then(common.mustCall()); + + assert.rejects(() => { + return new Promise((_resolve, reject) => { + const stream = fs.createWriteStream(path.join(blockedFolder, 'example')); + stream.on('error', reject); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(blockedFolder, 'example')), + })).then(common.mustCall()); +} + +// fs.utimes +{ + assert.throws(() => { + fs.utimes(blockedFile, new Date(), new Date(), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.utimes(relativeProtectedFile, new Date(), new Date(), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + + assert.throws(() => { + fs.utimes(path.join(blockedFolder, 'anyfile'), new Date(), new Date(), () => {}); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(blockedFolder, 'anyfile')), + })); +} + +// fs.mkdir +{ + assert.throws(() => { + fs.mkdir(path.join(blockedFolder, 'any-folder'), (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(blockedFolder, 'any-folder')), + })); + assert.throws(() => { + fs.mkdir(path.join(relativeProtectedFolder, 'any-folder'), (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(absoluteProtectedFolder, 'any-folder')), + })); +} + +// fs.rename +{ + assert.throws(() => { + fs.rename(blockedFile, path.join(blockedFile, 'renamed'), (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(blockedFile), + })); + assert.throws(() => { + fs.rename(relativeProtectedFile, path.join(relativeProtectedFile, 'renamed'), (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(absoluteProtectedFile), + })); + assert.throws(() => { + fs.rename(blockedFile, path.join(regularFolder, 'renamed'), (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(blockedFile), + })); + + assert.throws(() => { + fs.rename(regularFile, path.join(blockedFolder, 'renamed'), (err) => { + assert.ifError(err); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(blockedFolder, 'renamed')), + })); +} + +// fs.copyFile +{ + assert.throws(() => { + fs.copyFileSync(regularFile, path.join(blockedFolder, 'any-file')); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(blockedFolder, 'any-file')), + })); + assert.throws(() => { + fs.copyFileSync(regularFile, path.join(relativeProtectedFolder, 'any-file')); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(absoluteProtectedFolder, 'any-file')), + })); +} + +// fs.cp +{ + assert.throws(() => { + fs.cpSync(regularFile, path.join(blockedFolder, 'any-file')); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(blockedFolder, 'any-file')), + })); + assert.throws(() => { + fs.cpSync(regularFile, path.join(relativeProtectedFolder, 'any-file')); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(absoluteProtectedFolder, 'any-file')), + })); +} + +// fs.rm +{ + assert.throws(() => { + fs.rmSync(blockedFolder, { recursive: true }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(blockedFolder), + })); + assert.throws(() => { + fs.rmSync(relativeProtectedFolder, { recursive: true }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(absoluteProtectedFolder), + })); + + // The user shouldn't be capable to rmdir of a non-protected folder + // but that contains a protected file. + // The regularFolder contains a protected file + assert.throws(() => { + fs.rmSync(regularFolder, { recursive: true }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(blockedFile), + })); +} diff --git a/test/parallel/test-permission-deny-worker-threads-cli.js b/test/parallel/test-permission-deny-worker-threads-cli.js new file mode 100644 index 00000000000000..e817a7877226c1 --- /dev/null +++ b/test/parallel/test-permission-deny-worker-threads-cli.js @@ -0,0 +1,26 @@ +// Flags: --experimental-permission --allow-fs-read=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); +const assert = require('assert'); +const { + Worker, + isMainThread, +} = require('worker_threads'); + +// Guarantee the initial state +{ + assert.ok(!process.permission.has('worker')); +} + +if (isMainThread) { + assert.throws(() => { + new Worker(__filename); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'WorkerThreads', + })); +} else { + assert.fail('it should not be called'); +} diff --git a/test/parallel/test-permission-deny-worker-threads.js b/test/parallel/test-permission-deny-worker-threads.js new file mode 100644 index 00000000000000..741b7d1a57edf2 --- /dev/null +++ b/test/parallel/test-permission-deny-worker-threads.js @@ -0,0 +1,32 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-worker +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +const { + Worker, + isMainThread, +} = require('worker_threads'); +const { once } = require('events'); + +async function createWorker() { + // doesNotThrow + const worker = new Worker(__filename); + await once(worker, 'exit'); + // When a permission is set by API, the process shouldn't be able + // to create worker threads + assert.ok(process.permission.deny('worker')); + assert.throws(() => { + new Worker(__filename); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'WorkerThreads', + })); +} + +if (isMainThread) { + createWorker(); +} else { + process.exit(0); +} diff --git a/test/parallel/test-permission-deny.js b/test/parallel/test-permission-deny.js new file mode 100644 index 00000000000000..323be59c10fa83 --- /dev/null +++ b/test/parallel/test-permission-deny.js @@ -0,0 +1,97 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-fs-write=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); + +const fs = require('fs'); +const fsPromises = require('node:fs/promises'); +const assert = require('assert'); +const path = require('path'); +const fixtures = require('../common/fixtures'); + +const protectedFolder = fixtures.path('permission', 'deny'); +const protectedFile = fixtures.path('permission', 'deny', 'protected-file.md'); +const regularFile = fixtures.path('permission', 'deny', 'regular-file.md'); + +// Assert has and deny exists +{ + assert.ok(typeof process.permission.has === 'function'); + assert.ok(typeof process.permission.deny === 'function'); +} + +// Guarantee the initial state when no flags +{ + assert.ok(process.permission.has('fs.read')); + assert.ok(process.permission.has('fs.write')); + + assert.ok(process.permission.has('fs.read', protectedFile)); + assert.ok(process.permission.has('fs.read', regularFile)); + + assert.ok(process.permission.has('fs.write', protectedFolder)); + assert.ok(process.permission.has('fs.write', regularFile)); + + // doesNotThrow + fs.readFileSync(protectedFile); +} + +// Deny access to fs.read +{ + assert.ok(process.permission.deny('fs.read', [protectedFile])); + assert.ok(process.permission.has('fs.read')); + assert.ok(process.permission.has('fs.write')); + + assert.ok(process.permission.has('fs.read', regularFile)); + assert.ok(!process.permission.has('fs.read', protectedFile)); + + assert.ok(process.permission.has('fs.write', protectedFolder)); + assert.ok(process.permission.has('fs.write', regularFile)); + + assert.rejects(() => { + return fsPromises.readFile(protectedFile); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemRead', + })).then(common.mustCall()); + + // doesNotThrow + fs.openSync(regularFile, 'w'); +} + +// Deny access to fs.write +{ + assert.ok(process.permission.deny('fs.write', [protectedFolder])); + assert.ok(process.permission.has('fs.read')); + assert.ok(process.permission.has('fs.write')); + + assert.ok(!process.permission.has('fs.read', protectedFile)); + assert.ok(process.permission.has('fs.read', regularFile)); + + assert.ok(!process.permission.has('fs.write', protectedFolder)); + assert.ok(!process.permission.has('fs.write', regularFile)); + + assert.rejects(() => { + return fsPromises + .writeFile(path.join(protectedFolder, 'new-file'), 'data'); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + })).then(common.mustCall()); + + assert.throws(() => { + fs.openSync(regularFile, 'w'); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + })); +} + +// Should not crash if wrong parameter is provided +{ + // Array is expected as second parameter + assert.throws(() => { + process.permission.deny('fs.read', protectedFolder); + }, common.expectsError({ + code: 'ERR_INVALID_ARG_TYPE', + })); +} diff --git a/test/parallel/test-permission-experimental.js b/test/parallel/test-permission-experimental.js new file mode 100644 index 00000000000000..bec66e5a731a95 --- /dev/null +++ b/test/parallel/test-permission-experimental.js @@ -0,0 +1,13 @@ +// Flags: --experimental-permission --allow-fs-read=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); +const assert = require('assert'); + +// This test ensures that the experimental message is emitted +// when using permission system + +process.on('warning', common.mustCall((warning) => { + assert.match(warning.message, /Permission is an experimental feature/); +}, 1)); diff --git a/test/parallel/test-permission-fs-relative-path.js b/test/parallel/test-permission-fs-relative-path.js new file mode 100644 index 00000000000000..73f0635d986585 --- /dev/null +++ b/test/parallel/test-permission-fs-relative-path.js @@ -0,0 +1,48 @@ +// Flags: --experimental-permission --allow-fs-read=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const { spawnSync } = require('child_process'); + +const protectedFile = fixtures.path('permission', 'deny', 'protected-file.md'); +const relativeProtectedFile = './test/fixtures/permission/deny/protected-file.md'; + +// Note: for relative path on fs.* calls, check test-permission-deny-fs-[read/write].js files + +{ + // permission.deny relative path should work + assert.ok(process.permission.has('fs.read', protectedFile)); + assert.ok(process.permission.deny('fs.read', [relativeProtectedFile])); + assert.ok(!process.permission.has('fs.read', protectedFile)); +} + +{ + // permission.has relative path should work + assert.ok(!process.permission.has('fs.read', relativeProtectedFile)); +} + +{ + // Relative path as CLI args are NOT supported yet + const { status, stdout } = spawnSync( + process.execPath, + [ + '--experimental-permission', + '--allow-fs-read', '*', + '--allow-fs-write', '../fixtures/permission/deny/regular-file.md', + '-e', + ` + const path = require("path"); + const absolutePath = path.resolve("../fixtures/permission/deny/regular-file.md"); + console.log(process.permission.has("fs.write", absolutePath)); + `, + ] + ); + + const [fsWrite] = stdout.toString().split('\n'); + assert.strictEqual(fsWrite, 'false'); + assert.strictEqual(status, 0); +} diff --git a/test/parallel/test-permission-fs-windows-path.js b/test/parallel/test-permission-fs-windows-path.js new file mode 100644 index 00000000000000..90d377f0c7e609 --- /dev/null +++ b/test/parallel/test-permission-fs-windows-path.js @@ -0,0 +1,66 @@ +// Flags: --experimental-permission --allow-fs-read=* --allow-fs-write=* +'use strict'; + +const common = require('../common'); +common.skipIfWorker(); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +if (!common.isWindows) { + common.skip('windows test'); +} + +const protectedFolder = fixtures.path('permission', 'deny', 'protected-folder'); + +{ + assert.ok(process.permission.has('fs.write', protectedFolder)); + assert.ok(process.permission.deny('fs.write', [protectedFolder])); + assert.ok(!process.permission.has('fs.write', protectedFolder)); +} + +{ + assert.throws(() => { + fs.openSync(path.join(protectedFolder, 'protected-file.md'), 'w'); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(protectedFolder, 'protected-file.md')), + })); + + assert.rejects(() => { + return new Promise((_resolve, reject) => { + const stream = fs.createWriteStream(path.join(protectedFolder, 'protected-file.md')); + stream.on('error', reject); + }); + }, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + permission: 'FileSystemWrite', + resource: path.toNamespacedPath(path.join(protectedFolder, 'protected-file.md')), + })).then(common.mustCall()); +} + +{ + const { stdout } = spawnSync(process.execPath, [ + '--experimental-permission', '--allow-fs-write', 'C:\\\\', '-e', + 'console.log(process.permission.has("fs.write", "C:\\\\"))', + ]); + assert.strictEqual(stdout.toString(), 'true\n'); +} + +{ + assert.ok(process.permission.has('fs.write', 'C:\\home')); + assert.ok(process.permission.deny('fs.write', ['C:\\home'])); + assert.ok(!process.permission.has('fs.write', 'C:\\home')); +} + +{ + assert.ok(process.permission.has('fs.write', '\\\\?\\C:\\')); + assert.ok(process.permission.deny('fs.write', ['\\\\?\\C:\\'])); + // UNC aren't supported so far + assert.ok(process.permission.has('fs.write', 'C:/')); + assert.ok(process.permission.has('fs.write', '\\\\?\\C:\\')); +} diff --git a/test/parallel/test-permission-warning-flags.js b/test/parallel/test-permission-warning-flags.js new file mode 100644 index 00000000000000..f62b39fbe59b31 --- /dev/null +++ b/test/parallel/test-permission-warning-flags.js @@ -0,0 +1,23 @@ +'use strict'; + +require('../common'); +const { spawnSync } = require('child_process'); +const assert = require('assert'); + +const warnFlags = [ + '--allow-child-process', + '--allow-worker', +]; + +for (const flag of warnFlags) { + const { status, stderr } = spawnSync( + process.execPath, + [ + '--experimental-permission', flag, '-e', + 'setTimeout(() => {}, 1)', + ] + ); + + assert.match(stderr.toString(), new RegExp(`SecurityWarning: The flag ${flag} must be used with extreme caution`)); + assert.strictEqual(status, 0); +} diff --git a/tools/run-worker.js b/tools/run-worker.js index 7590e460a404ae..20f03f53e12184 100644 --- a/tools/run-worker.js +++ b/tools/run-worker.js @@ -7,5 +7,13 @@ if (typeof require === 'undefined') { const path = require('path'); const { Worker } = require('worker_threads'); +// When --experimental-permission is enabled, the process +// aren't able to spawn any worker unless --allow-worker is passed. +// Therefore, we skip the permission tests for custom-suites-freestyle +if (process.permission && !process.permission.has('worker')) { + console.log('1..0 # Skipped: Not being run with worker_threads permission'); + process.exit(0); +} + new Worker(path.resolve(process.cwd(), process.argv[2])) .on('exit', (code) => process.exitCode = code);