Skip to content

Securely Run User-Generated Code in Node.js with isolated-vm: A Step-by-Step Guide

Published: at 02:00 PM

When building systems that need to safely execute user-generated code or workflows, isolating the environment is crucial. The isolated-vm module in Node.js allows you to create isolated JavaScript environments with memory limits, helping you securely run untrusted code. In this article, I’ll guide you through setting up isolated-vm using a class structure, which is excellent if you’re building workflows or executing user-generated scripts.

Highlights:

Introduction to isolated-vm

isolated-vm is a powerful library that lets you create isolated JavaScript environments. It allows you to:

Class Setup

We will create a class IsolatedVmContext that encapsulates the logic for creating and managing an isolated environment using isolated-vm. The class will:

import ivm, { type Reference } from 'isolated-vm';
import { NotFoundError } from '../common/error/NotFoundError';
import helper from './http';

export class IsolatedVmContext {
  private readonly isolate: ivm.Isolate;
  private readonly context: ivm.Context;
  private readonly jail: Reference<Record<number | string | symbol, any>>;
  private readonly httpWrapper: {
    sendGetRequest: (...args: any[]) => Promise<object>;
    sendPostRequest: (...args: any[]) => Promise<object>;
  };

  constructor(memoryLimit: number) {
    this.isolate = new ivm.Isolate({ memoryLimit });
    this.context = this.isolate.createContextSync();
    this.jail = this.context.global;
    this.httpWrapper = {
      sendGetRequest: helper.sendGetRequest,
      sendPostRequest: helper.sendPostRequest,
    };
  }

  async initializeContext(): Promise<void> {
    this.initializeJail();
    await this.initializeHttp();
  }

  // Adds normal helper functions to the isolated context
  initializeJail(): void {
    this.jail.setSync('global', this.jail.derefInto());

    this.jail.setSync('log', function (...args: any[]) {
      console.log(...args);
    });

    this.jail.setSync('btoa', function (str: string) {
      return btoa(str);
    });
  }

  // Adds async functions via evalClosure to enable network requests (and other functions as well!!)
  async initializeHttp(): Promise<void> {
    await this.context.evalClosure(
      `
        (function() {
          http = {
            sendGetRequest: function (...args) {
              return $0.apply(undefined, args, { arguments: { copy: true }, result: { promise: true, copy: true } });
            },
            sendPostRequest: function (...args) {
              return $1.apply(undefined, args, { arguments: { copy: true }, result: { promise: true, copy: true } });
            }
          };
        })();
      `,
      [this.httpWrapper.sendGetRequest, this.httpWrapper.sendPostRequest],
      {
        arguments: { reference: true },
      }
    );
  }

  // Executes user scripts within the isolated environment
  async compileUserScript(userScript: string, args = {}): Promise<any> {
    const script = await this.isolate.compileScript(userScript);
    await script.run(this.context);

    const fnReference = await this.context.global.get('execute', {
      reference: true,
    });

    if (fnReference.typeof === undefined) {
      throw new NotFoundError(
        "The function 'execute' could not be found.",
        "We couldn't find the requested function."
      );
    }

    const result: any = await fnReference.apply(undefined, [args], {
      arguments: { copy: true },
      result: { promise: true, copy: true },
    });

    return result;
  }
}

Helper File

For context, here’s the http external helper file for handling HTTP requests. This helper file, http.ts, uses axios to send GET and POST requests.

import axios, {
  AxiosError,
  type AxiosHeaders,
  type AxiosRequestConfig
} from 'axios'
import AppError from '../common/error/AppError'

// Function to generate headers based on service type
function generateHeaders(headersObject: AxiosHeaders): AxiosRequestConfig {
  const headers: AxiosRequestConfig = {
    headers: headersObject
  }

  return headers
}

// Function to send a request to a service
const sendGetRequest = async (
  headersObject: AxiosHeaders,
  apiUrl: string
): Promise<any> => {
  const headers = generateHeaders(headersObject)

  const response = await axios.get(apiUrl, headers)

  return response.data
}

// Function to send a request to a service
async function sendPostRequest(
  headersObject: AxiosHeaders,
  apiUrl: string,
  data: any = {}
): Promise<any> {
  try {
    const headers = generateHeaders(headersObject)

    const response = await axios.post(apiUrl, data, headers)

    return response.data
  } catch (error) {
    if (error instanceof AxiosError)
      throw new AppError(
        Error sending POST request to ${apiUrl}: ${JSON.stringify(error.response?.data)},
        'There was an error sending the POST request.'
      )
  }
}

export default { sendGetRequest, sendPostRequest }

Explanation

Constructor

Jail Setup for Helper Functions

The initializeJail method allows you to expose normal helper functions to the isolated context:

Asynchronous Function Setup via evalClosure

In addition to synchronous functions, you can also expose asynchronous helper functions using evalClosure. In our case, we add HTTP request methods to allow the isolated environment to perform network operations. This flexibility enables your isolated environment to interact with external APIs securely, without compromising the main application.

Script Execution

The compileUserScript method allows for running user-generated scripts within the isolated environment. The function looks for an execute method within the script and throws an error if it’s not found.

Example Usage

Here’s how you can use the IsolatedVmContext class to run a user script that performs a network request:

async function runExample(): Promise<void> {
  const isolatedContext = new IsolatedVmContext(32); // 32 MB memory limit
  await isolatedContext.initializeContext();

  const userScript = `
    async function execute({ authHeader, baseUrl }) {
      const url = baseUrl + "bcdr/device?_page=1&_perPage=100&showHiddenDevices=1&showChildResellerDevices=1";
      log(url);

      const devicesResponse = await http.sendGetRequest(
        { Authorization: authHeader },
        url
      );
      return devicesResponse;
    }
  `;

  const result = await isolatedContext.compileUserScript(userScript, {
    authHeader: 'Bearer your-token',
    baseUrl: 'https://api.example.com/',
  });

  console.log(result);
}

In this example, we create an instance of IsolatedVmContext, initialize the context, and run a user script that performs an HTTP GET request. The isolated environment handles everything securely, including network operations.

Tested on Node.js 20

This setup has been tested on Node.js 20, ensuring full compatibility with the latest features and optimizations available in this version. Lower versions of Node.js may or may not work, depending on the specific features and packages used. Therefore, it’s recommended to test thoroughly if you’re using Node.js versions earlier than 20.

Conclusion

Using isolated-vm with a class structure is an excellent approach for managing isolated environments in Node.js. This setup is particularly useful for running user-generated scripts or creating workflows that require isolation. By following the steps outlined in this article, you can:

This approach provides a robust framework for securely managing untrusted code in your Node.js applications.