Node.js Internals
VM Module: Sandbox and Isolation
Imagine: users are writing plugins for your application. How do you execute their code without allowing them to break the entire server? The VM Module is the first line of defense between trusted and untrusted code.
- **Webpack/Rollup** execute configuration files in isolated contexts (webpack.config.js can contain arbitrary JS)
- **Jest/Vitest** use VM for test isolation - each test gets its own global object so that mocks do not leak between files.
- **Figma Plugins** operate in isolated-vm - millions of user scripts are executed safely on Figma's servers.
- **Cloudflare Workers** compile user code into V8 Isolates with CPU/RAM limits (up to 50ms per request)
Why is code isolation needed
The **vm** module allows executing JavaScript code in isolated contexts - separate V8 environments with their own global objects. This is not complete isolation (one process, one memory), but protection against accidental or intentional access to your global scope.
**Key Idea:** VM creates a new global object but operates in the same V8 Isolate - prototypes, constructors, setTimeout are shared. This is **not an OS-level sandbox**, but a logical boundary.
Use Cases
- **Plugins and extensions** - users write code for your application (Webpack, Rollup, ESLint)
- **Template Engines** - execution of user template expressions (Handlebars, Nunjucks)
- **REPL and Interactive Environments** - Node.js REPL, online IDEs, Jupyter-like notebooks
- **Testing** - isolation of tests from each other (Jest uses vm for module isolation)
- **Config-as-Code** - secure execution of configuration files (.js configs instead of JSON)
**vm ≠ security!** Code in a VM can: - Loop indefinitely (DoS attack) - Consume all memory (no RAM limit) - Escape through prototypes (`constructor.constructor('return this')()`) - Use CPU at 100% For real security, you need **isolated-vm** or **Worker Threads**.
Why is `vm.runInNewContext` safer than `eval`, but not a complete sandbox?
Contexts: createContext and runInContext
**Context** - this is a V8 environment with its own global object. You can create a context once and reuse it for many scripts - this is faster than creating a new one each time.
API for working with contexts
| Method | Context | When to use |
|---|---|---|
| vm.runInThisContext(code) | Current global | Compilation without isolation (like eval, but without local scope) |
| vm.runInNewContext(code, sandbox) | Creates a new one each time | One-time execution (slowly) |
| vm.runInContext(code, context) | Reuses existing | Multiple execution (fast) |
**Optimization:** If you run the same script multiple times, compile it into `vm.Script` - V8 will cache the bytecode.
**Common mistake:** Forgetting to pass `console` into the context. Without this, `console.log` will be `undefined` inside the VM.
What is the advantage of `vm.Script` over `vm.runInNewContext`?
Creating a Secure Sandbox
A secure sandbox requires not only scope isolation but also resource control: **execution time**, **memory**, **API access**. The VM provides basic tools but does not protect against all attacks.
Resource limits
**Problem:** `timeout` only works for CPU-bound loops. If the code performs `await` or `setTimeout`, the timeout will not work (they are asynchronous, control flow exits the VM).
Restricting API access
**Tip:** Use `Object.freeze()` on passed objects so that the code cannot modify them: ```typescript const sandbox = { Math: Object.freeze(Math) }; ```
Proxy for access control
Use Case: Plugin System
If I don't pass require into the sandbox, the code won't be able to access it.
The code can escape through constructor.constructor('return this')() or Object.getPrototypeOf
The VM isolates only the global object, but the prototypes (Function, Object) remain shared. Through them, you can access the real global and require.
Which of these methods does NOT protect against a DoS attack through an infinite loop?
ESM modules in VM: vm.Module
**vm.Module** (Node.js 13+) allows executing ES modules (`import/export`) in an isolated context. This is needed for dynamic imports, module mocking in tests, or loading user modules.
**Important:** vm.Module is a low-level API. For regular tasks, use dynamic `import()` or mocking through Jest/Vitest.
Creating a synthetic module
Loading ESM from a string
importModuleDynamically: resolving dependencies
**Limitation:** `vm.Module` does not support CommonJS (`require`). To do this, you need to implement your own module loader or use libraries like `module-compiler`.
Use Case: Hot Module Reload
What is the difference between `vm.SyntheticModule` and `vm.SourceTextModule`?
VM vulnerabilities and alternatives
**VM is not a security sandbox.** There are known vulnerabilities that allow escaping from the isolated context and gaining access to `require`, `process`, and the file system.
Attack 1: Prototype Pollution
Attack 2: Constructor Escape
**Problem:** Even if you delete `Function`, it can be accessed through `({}).constructor.constructor`. You need to patch all prototypes - this is difficult and fragile.
Attack 3: Timing Attack (DoS)
Solution: isolated-vm
**isolated-vm** - a library from Figma that creates a separate V8 Isolate (like a separate V8 process) with full memory isolation and resource control.
| Solution | Isolation | Limits | Performance |
|---|---|---|---|
| vm.runInContext | Scope only | timeout (partial) | Quickly |
| isolated-vm | Full (separate V8) | CPU, RAM, timeout | Medium (data copying) |
| Worker Threads | Separate thread | terminate() | Medium (data copying) |
| Child Process | Separate process | kill() | Slowly (IPC) |
**When to use what:** - **vm** - for trusted code (templates, configs, REPL) - **isolated-vm** - for untrusted code (plugins, user scripts) - **Worker Threads** - for CPU-intensive tasks - **Child Process** - for maximum isolation (Docker-in-Docker)
Best Practices
- **Never use vm for untrusted code** - only for trusted or limited-trust (your plugins)
- **Whitelist API** - pass only the necessary functions, not the entire `global`.
- **Object.freeze()** - freeze all passed objects and prototypes
- **Timeout + memory monitoring** - monitor `process.memoryUsage()` and kill long-running scripts
- **Content Security Policy** - if it's a web context, use CSP to block eval
- **Code review** - check user code before execution (static analysis)
- **Rate limiting** - limit the execution frequency (no more than N scripts per minute)
If I use vm.runInContext with a timeout, is my server protected from DoS?
Timeout protects only against CPU-bound loops, but not against async recursion, memory leaks, or prototype pollution.
VM is a logical isolation of scope, not resources. For real protection, you need: 1. **isolated-vm** or **Worker Threads** for memory isolation 2. **process.memoryUsage()** monitoring 3. **Rate limiting** at the application level 4. **Static analysis** of code before execution
Why is isolated-vm safer than vm.runInContext?
Key Ideas
- **VM ≠ security:** isolates scope, but does not protect against DoS, memory leaks, prototype pollution
- **vm.createContext()** is faster than vm.runInNewContext() - reuse contexts
- **vm.Script** caches bytecode - use it for multiple executions of the same script
- **vm.Module** supports ESM (import/export), but not CommonJS (require)
- **isolated-vm** - the only production-ready solution for untrusted code (separate V8 Isolate)
Related topics
VM Module - part of the isolation and parallelism ecosystem in Node.js:
- Worker Threads — An alternative to VM for CPU-intensive tasks is a separate thread with MessagePort for data exchange.
- Child Processes — Maximum isolation through fork() - separate memory and OS process, but slow IPC
- Cluster Module — Load balancing through forks - each worker in its own process, but without code isolation
Вопросы для размышления
- Which parts of your application can execute untrusted code? Configs, plugins, templates?
- If you are using eval() or new Function(), can they be replaced with VM for greater security?
- For which tasks is vm.runInContext sufficient, and where is isolated-vm or Worker Threads needed?