The security concerns of a JavaScript sandbox with the Node.js VM module

·

5 min read


Were you tasked with building a product that requires the execution of dynamic JavaScript originating from end users? You might think building it on-top of Node.js VM module is a viable way to create a JavaScript sandbox. In this article, we’ll learn why that’s far from being a recommended approach and the security implications of doing so.

Every now and then there’s a project that challenges the rudimentary and routine backend development. APIs? Message queues? Heavy processing and computational requirements? Nah. Here’s a backlog story for you to consider:

As a user,
I want to write and execute my own custom JavaScript code,
So that it can run within the platform and provide me feedback.

That’s a bit ambiguous because it’s a generic use-case about executing untrusted code from users, but there are some real-world examples of when that would be necessary, such as:

  • The replit product allows you to code in the cloud and execute code in a custom IDE

  • Various “leet code” code practice and interview platforms allow you to write custom code, such as to implement a given algorithm, or write tests for one.

Enter the Node.js VM module.

The Node.js VM module

Using the core node:vm module allows developers to compile and run dynamically provided code within V8 Virtual Machine contexts. At first, this might trigger you to the eval JavaScript function, which is a known security risk, but is the Node.js VM module safer? It does say “virtual machine”.

Let’s see a practical example:

const vm = require("node:vm");

const productExpirationDays = 14;

// This works just fine - it is a custom code that manipulates
// the data in the context and only there.
const userInputCustomJavaScriptCode = "userCustomNickname = 'Johnny Mnemonic';";

const context = { userCustomNickname: "John Nash" };
vm.createContext(context);

vm.runInContext(userInputCustomJavaScriptCode, context);

console.log(context.userCustomNickname);
console.log(context.productExpirationDays);
console.log(productExpirationDays);

In the above code snippet, user originated input of a custom JavaScript code (noted by the variable userInputCustomJavaScriptCode) is specifically executed within a given context of variables vm.runInContext(). This snippet of code runs successfully and would output the following results:

Johnny Mnemonic
undefined
14

Attackers, however, may choose to abuse this sort of dynamic JavaScript code by manipulating other variables than those that were originally assigned. In this contrived Node.js example, there’s a variable named productExpirationDays which sets the expiration time for the user. What if an attacker changed the contents of their dynamic JavaScript code to set this to a higher threshold?

const userInputCustomJavaScriptCode = "userCustomNickname = 'Johnny Mnemonic'; productExpirationMinutes += 60";

If we were to replace the former user input example with this one — which attempts to increase the expiration days — we’d be greeted with the following error:

evalmachine.<anonymous>:1
userCustomNickname = 'Johnny Mnemonic'; productExpirationDays += 60
                                        ^

ReferenceError: productExpirationDays is not defined
    at evalmachine.<anonymous>:1:41
    at Script.runInContext (node:vm:141:12)
    at Object.runInContext (node:vm:297:6)

The reason for error is that there’s no productExpirationDays variable defined or existing within the scope of the context for the virtual machine that the dynamic JavaScript code is executed in.

All in all, it seems like we have found a way to securely run JavaScript code dynamically within a confined JavaScript sandbox.

An insecure JavaScript sandbox

The code example that uses Node.js VM module’s createContext() and vm.runInContext() was overly simplistic. Unfortunately, the reality is that harmful user input will often use smarter, more creative, and more effective ways to escape the JavaScript sandbox.

Let’s explore how an attacker can provide insecure code that will result in a denial of service attack for a running application. Consider the following Node.js script:

const vm = require("node:vm");

const userInputCustomJavaScriptCode =
  "userCustomNickname = 'Johnny Mnemonic'; while(true) {}";

const context = { userCustomNickname: "John Nash" };
vm.createContext(context);

vm.runInContext(userInputCustomJavaScriptCode, context);

// This will never run:
console.log("Never fear, I is here");

In the above code example, the user added an infinite loop, while(true) {}, to their user input. While the code doesn’t mutate any other application variables, it is introducing a denial of service attack.

The risks of the insecure JavaScript sandbox extends also to remote code execution. Using this.constructor.constructor we can refer to the JavaScript Function object. A JavaScript function has a characteristic in which it accepts code as string and will then execute it. Our attack will exploit this fact and — along with a immediately invoked function execution, known as IIFE for short — will print the environment variables for the running Node.js process:

const vm = require("node:vm");

const userInputCustomJavaScriptCode =
  "this.constructor.constructor('console.log(process.env)')()";

const context = { userCustomNickname: "John Nash" };
vm.createContext(context);

vm.runInContext(userInputCustomJavaScriptCode, context);

console.log("Mess with the best, drop like your envs!");

Running the above code snippet will print the output of process.env which lists all environment variables.

At this point, you should realize the impact a remote code execution can have when applied to running untrusted code in Node.js’s VM module. If users are allowed to run custom code, they have complete access to the Node.js server runtime and can spawn processes, access the file system, and more.

Summary

The implications of an insecure JavaScript sandbox in a Node.js environment are severe and could bring an entire application to a halt. We’ve seen how an attacker may abuse the JavaScript sandbox and provide harmful input that would degrade a Node.js application — resulting in a denial of service vulnerability. The attack surface didn’t end there. We’ve seen how remote code execution can happen too, which allows attackers to run custom code in Node.js server environments, risking the entire application platform.

In fact, the security hole created by a JavaScript sandbox using the Node.js VM module is only the beginning of a more elaborate data breach waiting to happen. Once a single Node.js application server is compromised, it may reveal and expose sensitive information such as access secrets to a database, or cloud services that would allow an attacker to further their exploitation and move laterally within the deployed network.

So, the best practice is to not rely on Node.js’ VM module as a secure sandbox to run untrusted JavaScript code. This note is clearly made in the Node.js API documentation, which reads: “The node:vm module is not a security mechanism. Do not use it to run untrusted code.

Secure your code for free

Create a Snyk account today to find and automatically fix security issues.

Sign up for free