Skip to content

[WIP] src,lib: policy permissions

Rodrigo Muino Tomonari requested to merge github/fork/jasnell/policies into master

This is a new take at work @addaleax had previously gone through to define access control policies for Node.js. The original PR https://github.com/nodejs/node/pull/22112 failed to make progress for a number of reasons.

This is a Work in progress that is not yet completed.

How does it work

There is no change to the default behavior of Node.js. Running the node binary with no command-line flags means that all permissions are granted by default as they are today.

Permissions may be denied or granted via command-line flags:

(example: deny network access)

$ node --policy-deny=net

A simple JavaScript API is provided to checking grants and applying a stricter policy:

if (!process.policy.check('net'))
  console.log('network apis are restricted');

// Deny file system modifications
process.policy.deny('fs.out');

There is no user-facing API for granting permissions from JavaScript.

All internal/* modules have access to a special runInPrivilegedScope() function that can be used to execute a synchronous function with no permission checking. For instance, suppose that Node.js is started without file system permissions (e.g. node --policy-deny=fs). Ordinarily this would mean that Node.js itself could not function because all file system permissions would be denied. The runInPriviledgedScope() utility (which is injected into internal modules the same way primordials and internalBinding is) makes it possible:

process.policy.deny('fs');
console.log(process.policy.check('fs.in')); // false!
console.log(process.policy.check('fs.out')); // false!

const { kPermissionFileSystemIn } = internalBinding('policy');
// available only inside internal/* module!
runInPrivilegedScope(() => {
  // run fs read operations here.
  console.log(process.policy.check('fs.in')); // true!
  console.log(process.policy.check('fs.out')); // true!
});

console.log(process.policy.check('fs.in')); // false!
console.log(process.policy.check('fs.out')); // false!

The runInPrivilegedScope() function can be bound such that...

function foo(a, b, c) {
  return a + b + c;
}

const privilegedFoo = runInPrivilegedScope.bind(this, foo);

console.log(privilegedFoo(1, 2, 3));

The function passed in to runInPrivilegedScope() is executed synchronously and the permission scope reverts as soon as the function returns. Care must be taken because any user code that is executed synchronously within the privileged scope will run with no permission checking.

At the C++ level, an equivalent mechanism is provided using the PrivilegedScope utility:

PrivilegedScope priv_scope(env);
// So long as priv_scope is in scope all permissions are granted

The Permission set is hierarchical. E.g. net, net.in, net.out. Denying or granting net denies or grants the entire branch.

If a denied API is invoked, a ERR_ACCESS_DENIED Node.js error would be thrown.

All permissions are set per process. Once denied, they are denied for the entire process, including all worker threads.

The permissions

  • inspector (controls access to the inspector API, implicitly denied)
  • addons (controls access to native addons, implicitly denied)
  • child_process (controls access to child processes, implicitly denied)
  • fs (controls access to file system)
    • fs.in (controls access to file system read-operations)
    • fs.out (controls access to file system write-operations)
  • net (controls access to network access)
    • net.in (controls access to network servers)
    • net.out (controls access to network clients)
  • wasi (controls access to experimental WASI)
  • process (controls access to APIs that manipulate process state)
  • signal (controls access to sending signals to other processes)
  • timing (controls access to high resolution timing APIs)
  • env (controls access to environment / user info)
  • workers (controls access to worker threads)
  • policy (controls access to policy permission checking)

Implicitly denied permissions are denied automatically when any other permission is denied.

Denying permissions within a scope

Given the way that runInPrivilegedScope() works, A denied permission will only be in effect while the scope is active. So, for instance, if I launch Node.js with net enabled, then call runInPrivilegedScope() and subsequently deny net, once the privileged scope pops off the stack net will be permitted again. This should be obvious but it's worth calling out.

The question is whether it should be possible to deny a permission in parent scopes from a child. To keep things simple, I would say no.

Additional work to come

The previous version of this PR included all of the module changes to enforce the policy. I've backed those out so we can focus first on the core mechanism here. I will be reapplying those changes later.

FAQ

  1. Is this a security mechanism?

This is a key question. I don't want to classify this as a security thing. It's a capability-centered policy configuration. Security for your Node.js process really needs to come from the environment (e.g. running the Node.js process in a sandboxed container or running under a restricted uid, etc). What this mechanism does is selectively disable certain APIs in the standard library so that they are not available to running scripts. One use case, for instance, would be running utilities like npx to run scripts that should not necessarily have the ability to open network connections by default.

  1. Is this just like deno's permissions?

Yes and no. Yes in that they cover the same fundamental ideas. No in that it uses a different way of expressing permissions and is not advertised as a security mechanism. For deno, for instance, you enable file system reads for specific paths. Here, file system access is either on or off.

And in case someone is wondering if this is opened in response to deno having this, work on this mechanism started a long time ago in #22112 but was put on hold and we've been meaning to revisit it. We (NearForm) have had some use cases for this for a while that we're just now coming back around to so we wanted to move it forward.

I don't currently have plans to align it with deno's permission model but that's obviously something we could discuss if there would be enough ecosystem benefit to do so and it covers all the use cases.

  1. Why not support grant requests at runtime?

The ability to grant permissions at runtime is ruled out here to prevent the ability for a script to maliciously elevate it's permissions. Because this information is stored in the V8 context, it would be possible for a native addon to manipulate this permissions so if any permissions are denied we automatically also deny native addons by default but when native addons are allowed it would be easy to manipulate things.

In most of the use scenarios we've modeled for using this, the ability to request a permission just never was a consideration. Imagine, for instance, code running on a server. These modules are going to be known to the developer, what capabilities they need will be known, and there just won't be a need to enable any kind of dynamic request mechanism.

  1. Have you benchmarked this yet?

Hahahah... um... no, not yet. Will get to that a bit later.

What Now?

I am opening this PR to start the discussion and the iteration on these ideas. Feedback is welcome and requested.

/cc @addaleax @mcollina

Checklist
  • make -j4 test (UNIX), or vcbuild test (Windows) passes
  • tests and/or benchmarks are included
  • documentation is changed or added
  • commit message follows commit guidelines

Merge request reports

Loading