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:
- The TypeScript transpiler is itself written in TypeScript and uses Node.js. If you aren't using Windows, it seems to only be installable via npm.
- Compilation and packaging tools like Babel and webpack are distributed via npm.
- Frameworks like Vue.js, Angular, and React seem to only be installable via npm. (You can use CDN-provided versions, but you'll pay a penalty in terms of usability and performance.)
- The Prettier code formatter is only installable via npm.
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:
...
node:x:1002:me
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 https://nodejs.org/dist/v14.15.0/node-v14.15.0-linux-x64.tar.xz
$ tar xvf node-v14.15.0-linux-x64.tar.xz
$ ln -s node-v14.15.0-linux-x64 node
The run_node.sh
script
I saved the following tiny script to a directory in my $PATH
as run_node.sh
:
#!/bin/bash
user=node
path=/usr/local/bin:/usr/bin:/bin:/home/${user}/node/bin:/home/${user}/.yarn/bin
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 thenode
executable.path
specifies the value of$PATH
environment variable. This needs to include Node'sbin
subdirectory (containingnode
,npm
,npx
, and globally-installed commands), and it should also contain a generic set of paths so that scripts will be able find executables likesh
. 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 therun_node.sh
script.chmod -f g+w …
updates some files written bynpm
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 withsudo
and then overwritesargv[0]
with$cmd
. tmux appears to useargv[0]
as the window name for long-running commands, and I'd prefer that it show e.g.npm
instead ofsudo
. The downside of doing this is thatps
will show theroot
user as running a command likenpm -u node -H env PATH=...
(even thoughroot
actually just ransudo
). 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 run_node.sh
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 -> run_node.sh
lrwxrwxrwx 1 me me 11 Nov 14 08:30 npm -> run_node.sh
lrwxrwxrwx 1 me me 11 Nov 14 08:30 npx -> run_node.sh
-rwxr-xr-x 1 me me 166 Nov 14 08:57 run_node.sh
At this point, I was able to run node
and npm
using my regular user account and
see normal output:
$ node --version
v14.15.0
$ npm --version
6.14.8
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 -> run_node.sh
lrwxrwxrwx 1 me me 11 Nov 14 08:48 vue -> run_node.sh
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 vianpm install
package-lock.json
- Lists versions and hashes for all dependenciesnode_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 run_node.sh
, 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.
- For a small Vue.js project, it looks like I'm currently pulling in 1,271 packages totaling 821 MB of code. [return]
- 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]
- 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]