A secure Node.js sandbox

Pål Thingbø
7 min readOct 7, 2019

--

Deprecated!

VM2 has now been deprecated, making most of this article useless. Please stay tuned for a solution using Node20 Permissions and the v8 Isolation API.

Thanks to the VM2 team for their efforts on trying to Sandbox Node.

Original article:

Securely sandboxing Node.js is a common requirement and has a wide range of applications. From simply allowing customers to test Javascript on your site to allowing customers to write custom code in middle layers in your application.

In this article we’ll build a Node sandbox that supports function invocation. Your customers will be able to write a Javascript function, or a module, that is executed securely in the sandbox. They might perform a simple calculation or access external resources with HTTP calls.

There are other are ways to accomplish this, for instance by using similar methods to Jailed. However, in this article we will give our customer full access to a Node-like environment, with very little boilerplate code.

We require that:

  1. Access to host machine resources are limited
  2. The solution is easy to deploy, debug and maintain
  3. Minimal overhead and use of external libraries
  4. Code must run in an isolated Javascript scope
  5. Limit available internal and external Node modules
  6. All customer functions run in single Node instance
  7. Zero downtime

Amazon Lambda

I want to make a clear distinction between this solution and Amazon Lambda. Lambda does not isolate the global scope. Whenever you invoke a Lambda function, you get access to the global scope, and you don’t know which Node instance you are in. That means that two function invocations can potentially access the same global objects, which we want to prevent.

Scopes

In the web browser, window is a reference to the global scope. In Node, it’s just called global. The global scope provides things such as global variables, an event loop and a module loader. This poses some problems for a sandbox:

  1. When you start a Node instance, the global scope provides access to system resources that you might want to limit, such as the process object.
  2. Global scopes are shared amongst all function invocations. This can lead to concurrency problems, and is vulnerable to attacks.
  3. The global scope includes the Node module loader, which means customer code can load any module on your file system. In newer Node versions (16+), module loaders are able to load libraries directly from http resources, which is worse.

We need some means of isolating the sandbox scope for each function invocation, and limit the resources it has access to.

For this, we need a sandbox wrapper.

Isolating the scope

Sandboxing Node has been available since the early versions of Node with Node VM. VM offers security if you provide a simple scope with only primitives:

const vm = require('vm');

const sandbox = { x: 2 };
vm.createContext(sandbox);

const code = `x += 40;`;
vm.runInContext(code, sandbox);

console.log(sandbox.x); // 42

This is a safe scope, but you don’t have access to any of the Node API’s in your code. No http calls or anything listed here. So, let’s say we want Node’s https module. We can provide the sandbox with the https module:

const vm = require('vm');
const sandbox = { https: require('https') };

vm.createContext(sandbox);
const code = `
new Promise(resolve => {
const req = https.get('https://dog.ceo/api/breeds/image/random', res => {
let data = '';
res.on('data', chunk => { data += chunk; } );
res.on('end', () => { resolve(data); } )
})
})
`;

vm.runInContext(code, sandbox).then(result => {
console.log(JSON.parse(result).message); // Dog image
});

However, once you provide the sandbox with anything but a primitive, like an object or a function, you also expose their constructor, and in turn, the sandbox’s constructor:

const vm = require('vm');
const sandbox = { someNonPrimitive: {} };

vm.createContext(sandbox);
const code = `
this.someNonPrimitive.constructor.constructor('return process')().env;
`;

console.log(vm.runInContext(code, sandbox).USER) // User name

Exposing the name of the Node.JS process user is probably not a good idea.

VM2

VM2 takes a different approach. It wraps any sandbox non primitives’ constructor and __proto__ in proxies. Proxies can be thought of as decorators for objects and functions. Here is an example getter from VM2:

VM2 can also limit available internal and external Node modules by whitelisting them. Preventing access to the filesystem will greatly increase the security of your sandbox.

Beware however, that even if fs is not available in the sandbox scope, imported modules can still require them. For this reason, you should not whitelist any external modules that require fs. If you absolutely need a library that requires fs, fork or clone it and remove the fs dependency.

A major vulnerability was just discovered VM2 (https://security.snyk.io/vuln/SNYK-JS-VM2-2309905). Be sure you are using the latest version.

State and logging

If your sandbox wrapper is stateless there is less chance of it failing. The sandbox wrapper is stateless if it does not store data anywhere, including in memory. After a function call, it returns to it’s inital state, which is listening for new customer code to run.

Logging might be something that you want in your wrapper, or you might want to supply a logging mechanism to customer code. You might also want to catch exceptions and give meaningful output to customers.

Both of these criteria advocate that you implement your wrapper as a network service, and that customer code is sent to your wrapper via the network. If you proceed with protecting your host system with gVisor, this is your route; to build a stateless, no-fs, sandbox wrapper.

Protecting the host system

VM2 provides an isolated scope and protection from vulnerabilities in Node. However, this protection is high in the stack. What if:

  • Your sandbox fails. You might have a bug in your sandbox that exposes the fs object.
  • One of your white-listed dependencies has an fs dependency with a function that reads any file from the file system.
  • VM2 fails. There is a bug in VM2 that exposes Node’s global object or parts of it.
  • Node fails. There is a bug in Node which exposes your file system to the sandbox scope.
  • Any of Node’s dependencies fail, revealing an exploitable bug.

gVisor, released as open source from Google, is easy to install and provides complete protection from the host system. This will prevent functions from gaining access to host resources.

gVisor is implemented as a Docker runtime, and works as a light weight virtual machine. If you are running gVisor with Docker Compose, there is a network issue you should be aware of. See the last comment in this article.

Try the code

The following details will get you up and running with a secure Node sandbox. We assume you have installed NodeJS.

Create a working directory:

$ mkdir nodesecure
$ cd nodesecure

Install got and VM2 (for testing purposes):

$ npm init -y
$ npm i --save vm2 got

Save the following file as nodesecure.js. This example shows how you can use async/await instead of callbacks inside VM2.

// nodesecure.js
const { NodeVM } = require("vm2");
const { join } = require("path");
const fs = require("fs");

const vm = new NodeVM({
sandbox: {},
require: {
external: ["axios"],
builtin: ["url", "crypto"],
root: join(__dirname, "node_modules")
}
});

const code = fs.readFileSync(join(__dirname, 'dogcode.js'));
const axiosPath = join(__dirname, 'node_modules/axios');

vm.run(code)(axiosPath).then(result => {
console.log(result.message); // Dog image
});

Save the following file as dogcode.js. This is the file containing custom code. When you require custom code to export a function, you can send data to that function, which your users can inspect and use. Since VM2 requires you to use full paths when requiring modules, axiosPath is an example of this:

// dogcode.js
module.exports = async (axiosPath) => {
const axios = require(axiosPath);
const res = await axios.get('https://dog.ceo/api/breeds/image/random');
return res.data;
};

Test with
$ node nodesecure

If you like dogs, Ctrl +Click on the URL appearing in the terminal.

If your machine is not used by any other services than your Node instance, you could stop here and call it a day. However, if you want complete protection from the host system, install a virtual environment like gVisor.

gVisor

You can find instructions on how to install gVisor here. You also need to install Docker.

Once Docker and gVisor are installed, proceed to configure gVisor. Add gVisor as a Docker runtime. If you just installed Docker, this file likely doesn’t exist:

$ sudo nano /etc/docker/daemon.json

Add the following:

{
"runtimes": {
"runsc": {
"path": "/usr/local/bin/runsc"
}
}
}

Restart Docker:

$ sudo systemctl restart docker

Create a Docker file:

$ nano Dockerfile

Add the following, save and quit:

FROM node:8-alpine
RUN mkdir /home/alpine/
COPY ./ /home/alpine/nodesecure/
RUN cd /home/alpine/nodesecure/ && npm install --silent && npm i axios vm2 --silent
RUN addgroup -S alpine && adduser -S alpine -G alpine && chown -R alpine:alpine /home/alpine && chmod -R 777 /home/alpine/
USER alpine
CMD ["node", "/home/alpine/nodesecure/nodesecure.js"]

Build your Docker image. If you change dogfile.js or nodesecure.js, you must rebuild your Docker image. In real life, the code in dogfile.js would likely be sent to a http server running in nodesecure.js. I’ve left this out so as not to overwhelm this article with code. Build the Docker image:

$ docker build -t nodesecure .

Run it:

$ docker run -ti --rm --runtime=runsc nodesecure

Ctrl+Click on the dog URL again (it’s cuter this time)!

That’s it! You’re all set and ready to have anyone write Javascript code securely in your application. Feel free to comment or clap.

Thank you!

A note on Docker Compose:

There is a known issue with Docker Compose and gVisor related to DNS lookups. For a workaround, check out this issue: https://github.com/google/gvisor/issues/115

--

--