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 indirect 1 , 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.
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
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
cmd="$(basename "$0")"
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
.
cmd
contains the executable name used to run the run_node.sh
script.
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.
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.
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
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.