NodeJS packages don't deserve your trust

A modest proposal

Another week, another npm supply chain attack. Yikes! People on hacker news are wringing their hands about what should be done. The problem seems dire.

Apparently I couldn't help myself. At 2am the other night I woke up, staring at the ceiling. I couldn't stop thinking about this problem. It seems .. frankly, solvable. But how?

I think I came up with an answer. Or, the sketch of an answer. Is it any good? Will it work? I think it might... You be the judge.

The problem

The fundamental problem with npm is that any package you install has full access to do whatever it wants on your computer. For example, packages can:

You think you're installing leftpad. But you're actually letting a stranger into your house while you aren't at home. They can do basically whatever they want.

And its not just your home. We give package authors full access to our servers and our webpages. These systems store something much more precious: Our users' personal data.

Most people are trustworthy. But occasionally people decide that if you're in Russia or Belarus, wiping your hard drive is fair play. And if you let literally thousands of unknown people into your house unattended, its no surprise when someone does something you don't like. Frankly, I'm surprised supply chain attacks don't happen more often.

We can't solve this by figuring out all the baddies and banning them. I learned this as a kid in the 90s playing a video game called Theme Park. Once you played it enough, some park visitors would start vandalizing the park. I remember reading a strategy guide which said "You can't just hire a security guard and put them at the front gate. Security guards can only kick out visitors after they've broken the rules."

We have the same problem. We can't preemptively figure out which developers don't deserve our trust.

Deno tries to solve this problem, but I don't think its good enough. Deno lets you specify at the command line what kinds of actions your program is allowed to perform. You need to explicitly give permission to your deno process to have access to the internet or your database files.

This is a start; but I don't think its good enough. Just because I'm making a web server, that doesn't mean leftpad should be allowed to access the internet. If I'm making a file server, the leftpad library shouldn't have access to my filesystem. Deno's permission model is a good start, but it just isn't fine grained enough. (That said, I'd certainly take it over nodejs's current approach.)

Capabilities to the rescue

I think we can solve this problem entirely. But it might require some changes to how nodejs works.

I'm taking inspiration here from an OpenBSD API called pledge. The way pledge works is that, when the program starts but before your program has done anything, you make a set of pledges. "I promise this program will not access any files outside of /some/path, or make any network connections to peers except for example.com. If the program is later compromised, none of the compromised code can do anything nasty.

But I think we can take this a bit further. Here's my idea:

  1. We add a new builtin nodejs library called capabilities, which can hand out capability tokens. Capability tokens can only be created by the capabilities library.
  2. To make any privileged action (access the filesystem, the network, hardware, spawn child processes, load native npm modules, etc!), the caller needs to pass in an appropriate capability token. Most functions in fs, net, child_process and others will need a capability field added. Most of these methods already take an options object, so it shouldn't be too hard to add a capability token there.
  3. Every capability token has a scope. The scope specifies what the bearer of that token is allowed to do. For example, a capability might give you read/write access to the /var/data directory. The capability library lets you narrow a capability, but capabilities can never be widened. So if a library has a capability for arbitrary network access, it can create a narrowed token which only has network access to your database server. That capability can be then passed to the database client library.
  4. When your program launches, your main package (and only your main package!) gets access to a wildcard "do anything" capability. You can narrow & pass this capability token to other packages, depending on what you want them to do.

So, something like this:

// server.js
const cap = require('capabilities')
const express = require('express')

const rootToken = cap.claimRootToken() // More on this below

const httpServerToken = cap.narrow(rootToken, {
  // Scope of the new token
  net: {kind: 'listen', address: 'localhost'}
})

const app = express()
app.get('/', (req, res) => {
  res.send('Welcome to my lair of funk')
})

app.listen(4321, {
  // If we don't pass a token, express can't function!
  token: httpServerToken
})

Express doesn't need to do anything fancy with the capability token. It just passes it to the http library behind the scenes. Whats new is that lots of things are not in the token. Because the token we passed only allows network access, express is banned from reading your filesystem, opening new network connections, running shell scripts, or really anything dangerous that we haven't explicitly allowed.

There's lots of things to nut out here, but I've put a simple sketch of what the capability module might look like at the bottom of this post.

Unfortunately, its not that simple. There are a few other thorny details to figure out too!

What about existing code?

We make the entire capability system opt in at the command line level. If you don't pass --strict-capabilities, then nodejs works like it does now, where any script can do anything.

Production web servers should enable this flag, but existing code should keep working.

How would your root package get the root capability token?

The first idea is something simple like this:

import * as cap from 'capabilities'

// This method can only be called once
const rootToken = cap.claimRootToken()

But the danger of this approach is that attackers can run code before we get the root token. And if they can do that, they can probably get the root access token themselves and do nasty stuff.

import * as cap from 'capabilities'
import 'attackers_code'

const rootToken = cap.claimRootToken()

Unfortunately, ES modules require all import statements to be at the top of your file, before any code executes. You could work around this restriction by importing a local file first, which immediately claims the root token. But thats super awkward. I don't want a hello world web server to need (at a minimum) 2 source code files.

There might be a way to fix that with some weird ES6 getters, or by some deep V8 wizardry or something:

import {rootToken} from 'capabilities' // rootToken only be *imported* once? Is this possible?
import 'attackers_code' // Its too late for you!

Or maybe nodejs just passes it in via module.capability / import.meta.capability or something. For example:

const rootToken = module.claimToken()

One way or another, this seems technically solvable.

What about packages which never get updated?

We probably don't need to solve this for version 1.

But if we did, we might be able to add a method in the capability module to "bless" a package. Eg:

const httpServerToken = narrow(rootToken, {net: {kind: 'listen'}})
bless("express@^4.17.3", httpServerToken)

Then any direct system call from that library acts as if it had the capability we pass in. (And nothing else).

Its a bit hacky though. I mean, how can you tell if a method call comes from a specific package? Thats tricky, but it should be possible. The simplest answer is we could look at the call stack to see if the immediately preceeding item is in a blessed package. You can already inspect the call stack via new Error().stack, but thats slow, and probably corruptible from javascript code. I bet we could do something cleaner from native code.

There might also be scope for mischief via callbacks with this approach. Or someone could edit a package's methods.

How can we prevent javascript's dynamism from making this security system swiss cheese?

This is a real problem.

As an aside, I'm worried if we wait for a perfectly secure solution before launching a capabilities system, then we'll never solve this problem at all. If "mostly secure" is as good as we can get, it still might be better than the current situation. (Though smart people may well disagree with me.)

Javascript is weird, and I'm worried there might be ways to escape this little sandbox. For example:

const express = require('express')

const oldListen = express.application.listen
express.application.listen = (...args) => {
  doNastyStuffAsExpress() // Oh no!
  oldListen(...args)
}

But this wouldn't work because the new function isn't part of the express package (even if its called via app.listen(). There may be a way around that. Maybe via an eval() call?

Object.defineProperty(String.prototype, "length", {
  get() {
    eval("console.log('steal the root token')")
  }
})

This code fails, but I don't know how strong the protections are:

$ node
Welcome to Node.js v16.6.1.
> Object.defineProperty(String.prototype, "length", { get() { console.log('nasty') } })
Uncaught TypeError: Cannot redefine property: length
    at Function.defineProperty (<anonymous>)

I might not be smart enough to figure out a way to pierce this security envelope, but maybe you are? This is a new security level. We need some smart security minds to have a play and see if they can bolt this thing down.

Directly editing the prototype of built in javascript classes like String and Array is considered bad form these days. I'd be happy to ban some of that dynamism entirely if the result is better security. If we have to ban eval in strict capabilities mode, frankly I'd be delighted.

If some packages in npm misbehave with capability based sandboxing enabled, thats fine. We can either fix them or boot them from our production systems. There is no shortage of excellent packages in npm. (If you can find them.)

Package install scripts

Npm packages are also allowed to run arbitrary shell scripts on your computer when you install them, via lifecycle events in package.json. I understand it - but I really wish this feature didn't exist, because there's almost no valid uses for it outside compiling your module. And modules should be compiled before they're published, not after.

There are vanishingly few legitimate uses for npm install scripts - almost no popular npm modules use them. But there's a mountain of malicious ways to abuse them.

Now, npm install already sort of has an answer to this problem - which is its --ignore-scripts option. But I bet almost nobody knows about that option, or uses it.

This might be the most controversial (and difficult to change) recommendation here - I think npm should ignore npm install scripts by default. Or maybe, by default prompt the user instead of just doing this stuff blindly:

$ npm install isobject
Installed package `isobject` wants to run a script on your computer to function. Blindly trust this package? (Y/n): n

Closing thoughts

Anyway, thats the core idea. We add capability tokens to nodejs. Packages need a capability token in order to do any privileged actions - like spawn child processes, load native modules, run scripts, access the filesystem or the internet.

We have some problems to work out:

But the javascript ecosystem has plenty of smart people. I think this is a challenge worth taking on. The security of our computers, and our users' data depends on it.

(And as an added bonus, it would make it impossible to sneak dirty telemetry and things like that into npm modules.)

Nodejs has a massive, dynamic ecosystem of 3rd party packages. We should be able to depend on arbitrary libraries without giving them the keys to the kingdom. We just need to do some work to make it happen.

And when I say "we", I mean "you". I'm too busy building CRDTs to join this fight. We only get this future if people like you step forward to build it. Are you up for the challenge?

Appendix: How do we write Node's capabilities library?

Here is a rough sketch of what nodejs's capabilities library might look like:

const registry = new Map()

let rootToken = new Symbol() // Special global wildcard token
registry.set(rootToken, {scope: '*'}) // What do scopes look like?

function getRoot() {
  if (rootToken === null) { throw Error('...'); }
  let token = rootToken
  rootToken = null
  return token
}

function hasScope(symbol, desiredScope) {
  const scope = registry.get(symbol)
  // ... And check if desiredScope is a subset of scope.
}

function narrow(parentToken, requestedScope) {
  if (!hasScope(parentToken, requestedScope)) {
    throw new Error("Nice try, evildoer!")
  }

  const narrowedToken = new Symbol()
  registry.set(narrowedToken, requestedScope)
  return narrowedToken
}

module.exports = {getRoot, hasScope, narrow}