Toggle theme

Running Node.js and npm JavaScript code as an unprivileged user


Node.js is used to run JavaScript outside of a browser (and without the sandboxing that browsers provide), and npm is used to install any of more than a million packages that are uploaded and maintained by, well… anyone.

If letting untrusted parties run non-sandboxed code on your computer makes you feel uneasy, well, I'm in agreement with you! This isn't unlike the problem that Linux distributions face, but with something like Debian's "stable" distribution, I'm at least somewhat able to convince myself that packages will have had plenty of time to be scrutinized before they make it to my computer. In contrast, the npm ecosystem seems to be all about frequent updates and loose dependencies.

Even if none of the packages that you ask npm to install contain malicious code, you're also trusting all of those packages' authors not to directly or indirectly depend on other malicious packages. Oh, and you're also trusting the authors of all of your dependencies, both direct and indirect1, to follow good-enough security practices that attackers can't compromise their accounts and upload trojaned versions of their packages. In 2017, it was apparently easy to get direct publish access to 14% of all npm packages, potentially giving access to 54% of packages (!) through dependency chains.

The problem of malicious packages isn't unique to npm — for example, Python's PyPI repository is vulnerable to similar attacks.2 But while I can usually avoid installing random Python code from PyPI (often by installing an older version packaged by Debian), npm is essentially unavoidable in modern web development:

I try to keep sensitive information (e.g. browser cookie stores) off of computers that are using npm, but it's hard to completely avoid this on a development system — at the very least, it will likely contain credentials for pushing code changes to a remote repository.

I figured that I could pick some low-hanging fruit by using a separate user account to run Node.js processes. That way, any malicious code should be restricted by traditional Unix filesystem permissions and unable to read files that traditionally have 0600 (i.e. only owner-accessible) permissions, like .ssh and gnupg directories.3 The rest of this doc describes how I went about setting this up.

Creating the user and installing Node.js

First, I created a new node user:

$ sudo adduser --disabled-login node

I also edited the /etc/group file as the root user to include my personal account as a member of the new node group:


Then I switched to the node user and installed the current (as I'm writing this) LTS version of Node.js into the user's home directory:

$ sudo -u node bash -
$ cd
$ wget
$ tar xvf node-v14.15.0-linux-x64.tar.xz
$ ln -s node-v14.15.0-linux-x64 node

The script

I saved the following tiny script to a directory in my $PATH as

cmd="$(basename "$0")"
chmod -f g+w package.json package-lock.json || true
exec -a "$cmd" sudo -u "$user" -H env PATH="$path" "$cmd" "$@"

Breaking it down:

  • user specifies the user for running the node executable.
  • path specifies the value of $PATH environment variable. This needs to include Node's bin subdirectory (containing node, npm, npx, and globally-installed commands), and it should also contain a generic set of paths so that scripts will be able find executables like sh. The .yarn/bin path is needed if you're using the Yarn package manager rather than npm.
  • cmd contains the executable name used to run the script.
  • chmod -f g+w … updates some files written by npm to be group-writable if they're present in the current directory. This is needed since the writable bit is removed by operations like switching git branches.
  • exec -a "$cmd" replaces the script's process with sudo and then overwrites argv[0] with $cmd. tmux appears to use argv[0] as the window name for long-running commands, and I'd prefer that it show e.g. npm instead of sudo. The downside of doing this is that ps will show the root user as running a command like npm -u node -H env PATH=... (even though root actually just ran sudo). It sounds like -a may be a Bash 5.0 feature, so you might need to remove it if you're using an older version.
  • sudo actually runs the requested command:
    • -u "$user" specifies that the command should be run as the less-privileged user.
    • -H (short for --set-home) sets the $HOME environment variable appropriately. This may already happen by default, but it doesn't seem to hurt to explicitly request it.
    • env PATH="$path" sets the process's $PATH environment variable to the value that was specified earlier.
    • $cmd" "$@" runs the originally-invoked command and passes quoted copies of any additional arguments to it.

Running standard Node.js executables

The next step was to create symbolic links to the script corresponding to the actual commands that will be run. I put these links in the same directory as the script.

The names of these symbolic links end up in the script's $cmd variable, so I needed one for each of the commands from Node's bin directory:

lrwxrwxrwx 1 me me   11 Nov 14 08:30 node ->
lrwxrwxrwx 1 me me   11 Nov 14 08:30 npm ->
lrwxrwxrwx 1 me me   11 Nov 14 08:30 npx ->
-rwxr-xr-x 1 me me  166 Nov 14 08:57

At this point, I was able to run node and npm using my regular user account and see normal output:

$ node --version
$ npm --version

If you previously had Node.js installed for your user account, make sure that you've moved the old installation out of your $PATH to avoid running it rather than the new symlinks. You can run which node to verify that your shell is choosing the symlinks rather than running the executables directly.

Installing global packages

Now that I could run npm, I installed a few global packages. I ran the following to install the Vue.js CLI and the Prettier code formatter:

$ npm install -g @vue/cli
$ npm install -g prettier

and then created the following symlinks corresponding to their executables:

lrwxrwxrwx 1 me me   11 Nov 14 08:49 prettier ->
lrwxrwxrwx 1 me me   11 Nov 14 08:48 vue ->

Using npm within projects

To use the npm command to manage a repository's dependencies, I need to give the node user write access to various files and directories:

  • package.json - Lists explicit dependencies added via npm install
  • package-lock.json - Lists versions and hashes for all dependencies
  • node_modules/ - Contains the packages themselves

The following two commands change ownership to the node group and give it write access:

$ sudo chown -R :node package.json package-lock.json node_modules
$ sudo chmod -R g+w   package.json package-lock.json node_modules

At this point, I can run Node.js commands like usual, but ps shows that the processes are running as the node user rather than my real account.

Passing additional environment variables

Some npm-managed executables may need additional environment variables. For example, GOOGLE_APPLICATION_CREDENTIALS can be set when running the firebase command installed by firebase-tools to specify an alternate credential file. To instruct sudo to preserve specific environment variables, you can add arguments specifying additional mappings after PATH="$path" in, e.g. GOOGLE_APPLICATION_CREDENTIALS="$GOOGLE_APPLICATION_CREDENTIALS".

Additional permissions

Some npm packages may launch other processes that expect additional permissions.

For example, the Nightwatch.js end-to-end testing framework uses ChromeDriver to automate a Chrome process. By default (on Linux), Chrome will attempt to connect to the local X11 server so it can display a window. If the node user doesn't have access to do this, you may receive a cryptic error message like the following:

Error: An error occurred while retrieving a new session: "unknown error: Chrome failed to start: crashed."
     at endReadableNT (internal/streams/readable.js:1334:12)
     at processTicksAndRejections (internal/process/task_queues.js:82:21)

You could copy your regular account's $HOME/.Xauthority file to /home/node to work around this, but note that doing so gives any process running as the node user the ability to sniff your keystrokes or steal the contents of other apps' windows. You may want to consider other options like Xvfb — prefacing the testing command with xvfb-run -s ':99 -ac -shmem -screen 0 1600x1200x16' … works for me.

  1. For a small Vue.js project, it looks like I'm currently pulling in 1,271 packages totaling 821 MB of code. [return]
  2. As usual, Go takes a restrictive, pragmatic approach: "... as a matter of both design and policy, the go command never runs user-specified code during a build." [return]
  3. This won't help if a package attempts to escalate its privileges by attacking the kernel, or starts a reverse shell and participates in DDoS attacks, or starts a long-running process to mine cryptocurrency, or… [return]