Uh oh! Looks like JavaScript is disabled.

Bitman requires JavaScript to fuel his thirst to end bad UX and lazy design, which is necessary to give you the best viewing experience possible. Please enable it to continue to our website.

/web - 3 min read

Javascript Sandbox for your Node Project

Utkarsh Mehta

Utkarsh Mehta

Developer

Sandbox with a pail and shovel

Table of contents

I recently encountered a situation in the client project, where I was expected to run custom user-provided code, inside my Node environment to transform data. We all know the security implication of opening up our server runtime to external code and if not done correctly, the results are deadly ranging from losing our user’s data to raising our cloud servers bills because of mining injections.

Fortunately, there is a package called vm2 which provides a sandbox for such situations so that we can provide the flexibility for our users and also keep our servers safe. Let us see how it can be configured and used.

VM2

VM2 is a sandbox that can run untrusted code with whitelisted Node's built-in modules. Securely!

Some pointers for the sandbox:

  1. Uses console output of Node process
  2. Can require external & builtin modules
  3. Has the ability to setup customized access to builtin modules
  4. Uses secure context for execution using VM module
  5. Uses proxies to keep the sandbox code isolated

Let’s get into the implementation,

declaration.js
const { NodeVM } = require("vm2")

//Declaring functions to make inject them
//into the sandbox
const UtilFunctions = {
  callAPI: () => {
    //Code to call API and get data
    return {
      data: [
        { id: 12342, name: "Utkarsh", city: "Pune" },
        { id: 12343, name: "Yash", city: "Mumbai" },
        { id: 12344, name: "Piyush", city: "Bengaluru" }
      ]
    }
  },
  isHoliday: (date) => {
    //Code to check if its a holiday info from db
    return { holiday: true, name: "Diwali" }
  }
}

As the functionality is abstracted from the sandbox, all users can do is to call the functions and use the return value if any.

vm config.js
//Disabling eval(), web assembly(wasm), external modules, setTimeout & setInterval
const vm = new NodeVM({
  eval: false,
  wasm: false,
  require: {
    //No buildin module allowed
    builtin: [],
    external: false,
    root: "./",
    import: []
  },
  sandbox: {
    setTimeout: null,
    setInterval: null,
    eval: null,
    //Providing access to UtilFunction through utils
    utils: UtilFunctions
  }
})

Here we are configuring the NodeVM to get the sandbox. We are disabling eval, wasm, external modules, setTimeout & setInterval. Also, we are not allowing any builtin modules to be used in the code. We are making the sandbox secure and execution swift with this config. Also, we are injecting the UtilFunction into the sandbox.

executeJS.js
//Adds an uncaughtException listener onto the vm to handle vm exceptions
function registerListeners() {
  vm.addListener("uncaughtException", _uncaughtException)
}

//Removes the uncaughtException listener from vm
function cleanup() {
  vm.removeListener("uncaughtException", _uncaughtException)
}

//Function to handle uncaughtException of vm
function _uncaughtException(err) {
  console.error("Asynchronous error caught.", err)
}

/* Function which is called to execute user injected JS code
 * code: String - User typed JS code is passed as a string
 * context: Object - The object on which the code performs transformations
 * Returns updated context or error
 */
const executeJS = (code, context) => {
  //Using Promise to convert callback based call to async call
  return new Promise((resolve, reject) => {
    //Enclosing user typed code with skeleton code for better control
    //So we get JS function in string format with user's code at the core
    const func = `module.exports = function (context, resolve, reject) {
            try{
                ${code}
                resolve(context);
            } catch(ex) {
                reject(ex);
            }
        }`
    // Add listener to catch errors
    registerListeners()
    //vm.run converts the string formated function to JS function with callback
    //which when called will execute the code inside sandbox i.e. sandboxedFunction
    let sandboxedFunction = vm.run(func)
    //Pass the context to sandboxedFunction and the callback functions
    //to handle success and error cases
    sandboxedFunction(
      context,
      (ctx) => {
        cleanup()
        resolve(ctx)
      },
      (error) => {
        cleanup()
        reject(error)
      }
    )
  })
}

Here we have declared function _uncaughtException to handle uncaughtExceptions thrown by VM. Function registerListeners will add the listener to VM and function cleanup will remove the listener from VM. This part is just to make sure the Node process won’t exit due to the user entered code. Moving on to executeJS function where all the magic happens, NodeVM uses callbacks in the sandbox i.e. (after the execution of code a callback will be called by sandbox to convey the result of execution) but usually, we prefer async/await for such functionality as some code will be waiting for the result of user JS execution.

So using promises we made this function async. Now executeJS take 2 arguments code & context. Context is a JSON object which is made available to the user entered JS code, code can make changes to context and after execution, context can be used further. In this way, we make sure that the JS execution sandbox is working as a transformer. Which at the core just modifies the context. But due to this structure for the Node process, the executeJS function is a BlackBox transformer making it better streamlined with your existing system.

Next, we declare func, which is nothing but the user entered code enclosed in a function that is exported. Now we have taken care of exception handling with the exported function itself. As we discussed that VM uses callbacks so we are passing context in the first argument and 2 callbacks to handle success(resolve) & failure(reject) cases. Then we will register listeners to VM. vm.run will return the exported function we formulated in func.

The final step is to call the exported function and passing context & callbacks. In the callbacks, we are calling cleanup to remove listeners from VM and resolving or rejecting the promise accordingly. Let’s test this,

test.js
const test = async () => {
  try {
    const code = `
            if (!context.day || !context.date) {
                throw new Error('Day and Date both are required!');
            }
            let holidayInfo = utils.isHoliday({ date: context.date });
            if(holidayInfo.holiday) {
                context.plan = 'Happy ' + holidayInfo.name;
            } else if (context.day.toLowerCase() == 'sunday' || context.day.toLowerCase() == 'saturday') {
                context.plan = 'Its Weekend! Go out!';
            } else {
                context.plan = 'Work!';
            }
        `
    const ctx = {
      day: "Saturday",
      date: "14-Nov-20"
    }
    let output = await executeJS(code, ctx)
    console.log(output)
  } catch (ex) {
    console.log(ex)
  }
}

test()

So, in our test, the code has the user entered code, which:

  1. Checks the context for date and day
  2. Uses isHoliday UtilFunction to check if its holiday
  3. Checks for weekend
  4. Sets plan on the context accordingly or throw an exception

The executeJS function will return modified ctx or throw exception according to data.

Thanks for reading… keep hacking…


Utkarsh Mehta

Utkarsh Mehta

Developer

He is still thinking what to write about him


Let’s build digital solutions together.
Get in touch
->
Lenny Face