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:
-
Supports network requests : Isolated environments can make HTTP requests via exposed functions.
-
Custom helper functions : Easily add both synchronous and asynchronous helper functions inside the isolated environment.
Introduction to isolated-vm
isolated-vm
is a powerful library that lets you create isolated JavaScript environments. It allows you to:
-
Set memory limits for scripts.
-
Create separate contexts for running code.
-
Control what functionality is exposed to the code running in the isolated environment. Using
isolated-vm
, you can ensure that untrusted or resource-heavy scripts don’t interfere with your main application, making it ideal for executing user-generated code or handling workflows that need isolation.
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:
-
Set up the isolated context with a memory limit.
-
Provide utilities for executing user scripts.
-
Expose basic functionalities like logging, helper functions, and HTTP requests.
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
-
Memory Limit : The class constructor takes a
memoryLimit
parameter, which defines how much memory the isolated environment can use. -
Isolate & Context : We create an
ivm.Isolate
instance, which provides the isolated environment, and acontext
object to represent the execution environment within the isolate. -
HTTP Wrapper : We wrap the HTTP functions (
sendGetRequest
andsendPostRequest
) to expose them inside the isolated context.
Jail Setup for Helper Functions
The initializeJail
method allows you to expose normal helper functions to the isolated context:
-
Global Reference : The
global
object in the isolated context is set to reference the jail. -
Log Function : A
log
function is exposed to allow logging from within the isolated context. -
Base64 Encoding : The
btoa
function is also exposed.
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:
-
Safely execute user-generated code in an isolated environment.
-
Perform network requests from within the isolated context.
-
Add both normal and asynchronous helper functions to extend the capabilities of your isolated environment.
This approach provides a robust framework for securely managing untrusted code in your Node.js applications.