DEV Community

Mika Torren
Mika Torren

Posted on

node:vm Is Not a Sandbox. Stop Using It Like One.

node:vm Is Not a Sandbox. Stop Using It Like One.

A critical CVE dropped this week on OneUptime, an open-source observability platform that's widely deployed with open registration on by default. The escape was this.constructor.constructor('return process')(). One line. The same line that's been in public writeups since 2017. The same line that's burned vm2 twenty-plus times. The same module that Node.js documentation warns you about at the top of the page, in a callout block, before you read anything else.

And yet here we are.

What Happened

OneUptime lets you write Custom JavaScript monitors, scripts that probe your infrastructure on a schedule. Those scripts run inside vm.runInContext() in a file called VMRunner.ts. Input validation is a Zod string check. That's it. No AST parsing, no keyword filtering, no attempt to inspect what you're actually running.

The probe that executes these monitors runs with network_mode: host and has ONEUPTIME_SECRET, DATABASE_PASSWORD, REDIS_PASSWORD, and CLICKHOUSE_PASSWORD in its environment. Because of course it does. It needs those to do its job. The sandbox was supposed to be the safety layer.

The permission required to create a Custom JS monitor is ProjectMember, the lowest role in the system. OneUptime has open registration. So the attack chain is: register an account, create a project (you're auto-granted ProjectMember), create a monitor, paste the escape, wait up to 60 seconds for the probe to poll. Full cluster credentials. Arbitrary command execution.

CVSS 9.8. Critical. Filed as tracker issue #2324.

Here's the part that made me do a double-take: OneUptime has a microservice called IsolatedVM. It sounds like it uses isolated-vm, the npm package that provides actual V8 isolate-based sandboxing. It does not. The IsolatedVM service calls the exact same VMRunner.runCodeInSandbox(). The name is cosmetic. Someone named the thing after the solution they didn't implement.

Why This Keeps Happening

The Node.js docs are not subtle about this. The node:vm module page opens with:

The node:vm module is not a security mechanism. Do not use it to run untrusted code.

That's the first thing. A warning block. Before any API docs, before any examples. And still, projects keep reaching for it.

I think the name is doing most of the damage. node:vm sounds like a virtual machine. It has runInNewContext() and createContext() and a whole API that looks like it's creating an isolated execution environment. What it's actually doing is giving your script a different global object. That's useful for scoping: it's why REPL environments use it, why some test runners use it. It was never designed to stop a hostile script from walking up the prototype chain and grabbing process.

The escape is trivial because V8 doesn't enforce the separation. this.constructor gives you the script's class. .constructor again gives you the Function constructor, which lives in the host context, not the sandbox context. Call it with 'return process' and you're back in the host process. The sandbox context is a scoping trick, not a security boundary. There's no wall. There's a label on the floor that says "wall."

vm2 was the community's answer to this. A library that wraps node:vm with proxy-based sanitization: intercept prototype access, block dangerous references, catch the known escape patterns. It hit 1M+ weekly downloads. 200,000+ GitHub projects depended on it. And it accumulated more than 20 known breakouts. The maintainers deprecated it in July 2023 after 8 critical advisories in a single year. The README said "contains critical security issues, do not use in production." Then it got revived in October 2025, and by January 2026 it had another critical CVE. Async functions returning globalPromise instead of localPromise, leading to unsanitized error objects with host constructor references. Different mechanism, same outcome.

Semgrep called it "playing whack-a-mole," which is exactly right. Every sanitization layer is a new attack surface. The proxy approach doesn't fix the underlying problem. It just makes the escape harder to find, which means it takes longer before someone finds it.

The Tradeoff Ladder Actually Exists

The frustrating thing is that the correct tools exist and are well-documented.

isolated-vm uses actual V8 isolates: separate heap, no shared prototype chain, real isolation within the same process. It's what vm2 maintainers recommended when they deprecated their library. It has overhead (isolate setup and teardown costs something), but it's real isolation, not a proxy layer hoping to catch everything.

If you need stronger guarantees, or if you're running code from genuinely untrusted sources at scale, you go up the ladder. QuickJS compiled to WASM gives you a JS interpreter running inside a WASM sandbox, which means V8 bugs in the host don't directly translate to escapes. Subprocess isolation gives you OS-level process boundaries. Containers give you the full thing.

The ladder:

  • node:vm — no isolation, don't use for security
  • vm2 — proxy illusion, 20+ escapes, deprecated, don't use
  • isolated-vm — real V8 isolates, same process, correct for most use cases
  • QuickJS/WASM — interpreter boundary, no shared V8 surface, slower
  • subprocess / container — OS boundary, correct for high-risk or multi-tenant

OneUptime's fix is to replace node:vm with isolated-vm. That's the right call. It's also the fix that was available in 2023 when vm2 was deprecated. The information has been out there.

The Naming Problem Is Real

I keep coming back to the IsolatedVM microservice name. Someone on that team knew isolated-vm existed. They named a service after it. They just didn't wire it up. That's not negligence exactly; it's a documentation failure in the codebase itself. The name communicates a security guarantee that the code doesn't provide. Anyone reading the architecture diagram would assume the isolation problem was solved.

This is the same failure mode as the module name. node:vm communicates "virtual machine." IsolatedVM communicates "isolated VM." Neither delivers what the name implies. When your security architecture depends on names being accurate, you're one confused developer away from a 9.8 CVE.

If you're running user-supplied code anywhere in your stack, check what you're actually using. Not what the service is called. Not what the wrapper library claims. What's the actual execution boundary? Is it node:vm? Because if it is, you have a floor label, not a wall.

The escape still works. It's one line. It's been one line for nine years.

Top comments (0)