Author Avatar Image
Alexander Reelsen

Backend developer, productivity fan, likes the JVM, full text search, distributed databases & systems

Node process permissions: An introduction
Jul 23, 2024
8 minutes read

TLDR; This post will talk about node.js process based permissions feature behind the --experimental-permission command line flag. We’ll also cover a little bit of history and why I think that such a feature is so important in an execution environment.

Note: This feature is still marked as Stability 1 - experimental and in active development, so there are no guarantees of BWC and it’s possible that new features are still being added. This article does not cover module based permissions as those are marked for removal.

What is a sandbox?

The basic idea of sandboxing an application comes from the idea of running other folks code on your local system (like all the JavaScript in your browser). As that code cannot be trusted, you want to limit the impact that potentially harmful code can have by running it within a limited execution environment. This environment monitors dangerous operations and stops the execution, or could also kill the whole process that is running to prevent further harm. Of course this sandbox needs to be trusted and secure and it must be hard to escape.

There are different ways of sandboxing. Starting an application in an own virtual machine could be a way of sandboxing. Using containers is another approach to isolate different programs (sometimes for different reasons, like resource management, sometimes for security). Often the runtime environment can also help, like node.js in this blog post or the Java Security Manager in the JVM. The runtime environment may offer primitives, so that you do not have to implement low level security mechanisms from the operating system yourself.

Reminder: Your code is a security risk

Nowadays sandboxing makes sense, even though you are often running your own code. First your own code should never be considered secure (unless you are genius), second applications come with such an insane amount of dependencies that it is basically impossible to audit or know all the code that you are running. Remember all those typo squatting dependency attacks recently or that bitcoin miner hidden in a dependency? That’s what we don’t want. Third, you code may not run the way you think it runs, like branch predictions on your CPU or buggy bytecode or buffer overflows that can be triggered by external actions.

landlock and seccomp

Under Linux there are two common mechanisms that can help to implement your own application level sandbox. The first one is seccomp, allowing you to effectively limit which system calls a process is allowed to execute. A common use-case is to prevent forking of processes by stopping fork, vfork, execve and execveat system calls. From a security perspective this prevents attackers to find a way to execute another process within the current one - for example spawning a bind shell within a web application would not work anymore.

Seccomp can be configured in many different tools: Kubernetes, systemd, Docker, Android, common browsers and many more.

I have written another blog post Using seccomp - Making your applications more secure some time ago. Feel free to read it, but it is not needed for the rest of this article.

The second library is landlock, a newer addition to the Linux kernel. Landlock allows to define rules which files a process is allowed to read and write, plus network access control, if a process is allowed to TCP bind or TCP connect.

While you could use seccomp and landlock in all your own environments, this would limit you to Linux as a system. Even though seccomp has similar implementations in other operating systems, they all differ.

Also, this would require you to understand all the different APIs under all the different operating systems. This is the reason why some runtime environments allow you to implement similar features across several operating systems using the same API, hiding all the complexity from the OS.

On top of that there is no landlock package for node.js available (there is one for python, go and rust). It’s not much better on the seccomp front. There is a five year old package named seccomp and newer fork that did not even update the link to another GitHub repository - which looks somewhat suspicious to me.

So if you want to use seccomp or landlock you would need to dive into the C API and provide a proper integration with node.

Java - a blast from the past

Before we dive into node.js, let’s talk about the godfather of sandboxing - Java. Back in the applet days it was normal to execute unknown code locally. So each JVM was configured with a policy that defined which files could be read and write, if reflection can be used, if threads may be created, if new network connections be created, if you can listen on a network socket and many more. You could even implement your own custom permissions.

One possibility is to have a custom configuration file to the JVM - like done in the applet days - or to configure this programmatically. Unfortunately with the introduction of project Loom (virtual threads under the JVM) the security manager is deprecated for future removal - which I am still sad about.

So I was pretty happy when I saw an experimental permission feature in node.js, allowing you to configure some basic security. Let’s dive deeper.

Configuring permissions

When node with the new --experimental-permission you can configure additional permissions on the command line. Take this example:

node --experimental-permission  -e \
  "const { execFile } = require('node:child_process'); execFile('/bin/ls')"

This returns

Error: Access to this API has been restricted
    at ChildProcess.spawn (node:internal/child_process:396:28)
    at spawn (node:child_process:760:9)
    at execFile (node:child_process:350:17)
    at [eval]:1:53
    at runScriptInThisContext (node:internal/vm:209:10)
    at node:internal/process/execution:118:14
    at [eval]-wrapper:6:24
    at runScript (node:internal/process/execution:101:62)
    at evalScript (node:internal/process/execution:136:3)
    at node:internal/main/eval_string:51:3 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'ChildProcess',
  resource: ''
}

Same for reading files

node --experimental-permission  -e \
  "const { readFileSync } = require('node:fs'); readFileSync('/tmp/test.txt');"

The above returns an error, we can however add the necessary permission:

node --experimental-permission --allow-fs-read=/tmp -e \
  "const { readFileSync } = require('node:fs'); readFileSync('/tmp/test.txt');"

Now the file can be read.

When enabling permissions, it’s very likely your build folder and the node_modules folder require reading permission.

Available permissions

The following permissions are available:

  • file system read permissions via --allow-fs-read
  • file system write permissions via --allow-fs-write
  • Allow native modules to call dlopen via --allow-addons
  • Allow spawning child process via --allow-child-process
  • Allow worker threads via --allow-worker
  • Inspector is disabled, seems there is no way for enabling
  • Allow WASI instances via --allow-wasi

Limitations

Production only

It’s obvious that this feature is for production use only, but should be disabled in development. The inspector cannot be enabled, and while using PHPStorm I was not able to debug a node.js process, as PhpStorm tries to load a javascript file from another location and sets a custom NODE_OPTIONS variable.

While in general it’s OK to not enable this feature for development, you still need to find a way to figure out if everything works before deploying to production.

The current documentation states:

When starting applications with the permission model enabled, you must ensure that no paths to which access has been granted contain relative symbolic links.

This is important. If your application has the ability to add symlinks, it may be hard to enforce security rules.

Post startup configuration

Right now permissions need to be configured on startup. This means you need to know in advance which paths are allowed to read and which are forbidden. If you have a service that is reading from a configuration file, then this is not an option. You would need to add some complexity and have a small process, that reads the configuration file and then creates a node.js commandline argument and forks a new node.js process with permissions enabled.

Programmatic access of configured options

If permissions are used, you can check the configured permissions, instead of blindly trying to read or write a file by using the process.permission.has() API that returns if a certain permission has been set. You can also call this with a file path as an argument.

Summary

In general I like this feature and to me it’s a move into the right direction. I think some command line flags need to become more exact, like only allowing certain binaries to be executed, or loading of dynamic libraries. deno is doing this already, and judging from the command line parameters, some heavy inspiration came from that. deno also has explicit deny operations for every allow operation.

The ability to configure permissions during startup, and after reading some initial configuration sounds important to me as well.

Also, consider this still in early development, as now people start to take a deeper look at it and will find more issues.

One more thing, that probably helps figuring out which permissions need to be granted: Add a logging-only mode, that logs which permissions are required or need to be set in order to activate permissions.

In general it’s hard to overstate, just how limiting such a feature can be for any attacker. Suddenly forking a new process, or creating network sockets to send data to another system becomes a lot harder. While it’s not impossible, the time that needs to be invested is increased against an attacker.

I’ll keep watching it and have also started to use it in production.

Resources

Final remarks

If you made it down here, wooow! Thanks for sticking with me. You can follow or contact me on mastodon, GitHub, LinkedIn or reach me via Email (just to tell me, you read this whole thing :-).

If there is anything to correct, drop me a note, and I am happy to fix and append a note to this post!

Same applies for questions. If you have question, go ahead and ask!

If you want me to speak about this, drop me an email!


Back to posts