Compatibility
Published
Running somebody else's code inside your process is a bargain with three terms: it gets a time budget, it does not get to generate more code, and when the call returns it is finished. All three terms are properties on an options object handed to the sandbox, and all three are names the runtime owns. Lose the names and you have not lost the sandbox. You have kept the sandbox and lost the terms.
What was measured
One file against node's vm module, driven through five protection profiles for the base column and both member-renaming profiles for the rest. It runs four pieces of hostile-shaped code: a plugin that spins for 600 milliseconds against a 60 millisecond budget, a plugin that calls eval inside a context configured to forbid code generation from strings, a plugin that schedules a promise callback and is expected to have settled by the time the call returns, and a plugin that throws so the incident frame can be read back.
Every value in the file differs from node's default on purpose, because an option whose value equals the runtime default proves nothing when it is removed. There is no default timeout, code generation from strings is allowed by default, microtasks are not drained by default, and the default script filename is a generic placeholder.
The base column is clean. All five profiles stopped the runaway plugin, blocked eval inside the sandbox, drained the microtask, and produced the same incident frame. Protecting a host that sandboxes untrusted code does not weaken the sandbox.
The time budget stops being enforced
The timeout arm is the one to show anybody who thinks this class of finding is theoretical. Before renaming, the plugin was stopped by the budget after 60 milliseconds and the run recorded that it had not exceeded 400 milliseconds. After renaming, the verdict reads that it ran to completion, and the elapsed-time check flipped to true.
There is no default timeout in node's vm module. Renaming the key does not shorten the budget or lengthen it; it removes the concept. Untrusted code now runs until it decides to stop, on the thread that was executing your request.
The loop in this measurement is bounded at 600 milliseconds precisely so that a missing budget could not hang the test. Real plugin code is not written with that courtesy. The same rename applied to a plugin with an unbounded loop does not produce a slow response; it produces an event loop that never comes back, which is a single-tenant outage caused by one tenant's code.
It is worth being clear about what the sandbox still does in that state, because the answer is not nothing. Isolation of the global object holds: the plugin still cannot reach your modules, your variables or the file system through the context you gave it. What is gone is the resource bound. That combination is unusually awkward, because the property most teams describe as the reason they trust the sandbox is intact, and the property that keeps a tenant from taking the process down is not.
There is a deployment shape that makes this considerably worse. Plugin hosts are often the part of a system that runs many tenants on one process precisely because the sandbox makes it safe. The cost of a missing timeout scales with that decision: it is not one request that hangs, it is every request served by that process, including every request belonging to tenants who did nothing.
Code generation comes back on, from either direction
The second arm renamed the key that carries the code-generation policy, and the third renamed the two flags inside it. Both produced the same result: eval inside the sandbox went from blocked to allowed, returning its computed value.
That symmetry is worth pausing on, because elsewhere in this series a container and a leaf behave very differently. Renaming a container your own code walks throws at first access, loudly, while renaming a leaf continues with a wrong value. Here both are silent, because neither object is walked by your code. The container is an option node reads; when it is missing, node applies the default policy. The flags are options node reads inside that object; when they are missing, node applies the default policy. Loud versus silent is decided by who does the reading, not by how deep the name sits.
The practical consequence is that a sandbox intended to forbid dynamic code accepts it again, and the only evidence is inside the sandbox, where by construction you are not watching.
Forbidding code generation is usually the second thing a plugin host does, after the timeout, and for a specific reason: it closes the gap between reviewing what a plugin contains and knowing what a plugin will run. A plugin that can call eval can assemble its payload at run time from data, which means a review of the submitted source is no longer a statement about behaviour. Renaming this option reopens that gap silently, and the plugins that were already submitted and approved are the ones running under it.
The call returns before the plugin has finished
The microtask arm is the subtlest measurement in the file, and it changes nothing about what the plugin can do. It changes when your code believes the plugin is done.
Configured to drain microtasks after evaluation, the sandbox returned a log with the plugin's callback already recorded. With the option's name renamed, the same call returned the log empty, because the promise had not settled yet. The value is not wrong; it is early.
That is a race handed to code that had none. Whatever runs next reads a half-finished result, and the plugin's callback lands afterwards, in a context that has already moved on. This is the failure that reproduces once in a thousand runs, never on a developer machine, and is diagnosed as a plugin bug because the plugin is where the missing data is.
The incident frame loses the tenant
The last silent arm renamed the script filename and its line offset. Before, an exception from a plugin carried a frame naming the tenant, the plugin file and the corrected line number. After, the frame reads the generic sandbox placeholder at line one.
Nothing about the failure changed. What changed is that the one artifact an operator uses to attribute the failure now attributes it to nothing. In a multi-tenant host, that is the difference between disabling one tenant's plugin and reading logs for an afternoon.
The line offset deserves its own mention, because it is the half that is wrong rather than missing. Plugin source is almost always wrapped before it is run, in a function header, a preamble, an injected helper. The offset is what subtracts that wrapper so a reported line number matches the file the plugin author wrote. Lose it and the numbers do not disappear, they shift, which is worse: the frame points confidently at a line that exists and is not the one that threw.
One arm in this file measured inert: renaming the context name changed no output at all. That is not evidence the option survives. The context name governs the label a debugger and an inspector session show, and this measurement never opens one, so the honest reading is that the arm was blind rather than clean. It is recorded here as a limit of the measurement, not as a result. That distinction has been earned the hard way elsewhere in this series, where arms that looked inert turned out to be measuring nothing at all.
The loud arm, and the code that keeps its names
The control arm renamed the array methods the file uses to accumulate its own output. It failed immediately, on the first line, with a type error naming a generated identifier. That is the good failure mode, and it is the argument for anchoring a member pattern to names you own.
That same arm produced the most instructive detail on this page. The plugin source inside the sandbox is a string. A rename pattern does not reach into string contents, so the sandboxed code still calls the method by its real name, and would run correctly if it were ever reached. The host that launches it is renamed and throws before it gets there.
Generalise that and it is a rule about sandbox hosts specifically. Code your build writes as text keeps its names; code your build compiles loses them. Any place where those two meet, a plugin API, a template, an expression evaluator, is a place where one side of an interface is renamed and the other is not.
What to do about it
Nothing here is a reason to avoid protecting a host that runs untrusted code, because protection alone changed nothing on any of the five profiles measured. The exposure is member renaming with a pattern that reaches node's vm option names.
Keep timeout, contextCodeGeneration, strings, wasm, microtaskMode, filename, lineOffset, columnOffset, contextName and importModuleDynamically out of that pattern, and prefer anchoring the pattern to your own naming convention over maintaining the list.
Then test the sandbox rather than its configuration. Run a plugin that spins forever and require the call to throw. Run a plugin that calls eval and require it to fail. Both tests are a few lines, both exercise the control rather than the object that describes it, and both were the only checks in this measurement that would have caught anything.
Those two tests are worth more than their length suggests, because they are also the tests that catch the ordinary regressions: a refactor that drops the options argument, a helper that rebuilds the object and forgets a key, an upgrade that renames an option upstream. Nothing about them is specific to protected builds. They are simply the only way to make a statement about a sandbox that is not a statement about a dictionary.
If your host runs plugins from more than one tenant, add a third: throw from inside a sandbox and assert that the frame names the tenant. It is the cheapest possible guard on the attribution you will need at exactly the moment you have no time to reconstruct it.
Frequently asked questions
Does obfuscation break a node vm sandbox?
Not in the default configuration. A file that runs untrusted code under a time budget, forbids code generation from strings, drains microtasks and sets an incident filename behaved identically on all five protection profiles measured. Sandbox options become a surface only when member renaming reaches the names node reads.
Can member renaming remove a sandbox timeout?
Yes. With the timeout key renamed, a plugin that spins for 600 milliseconds against a 60 millisecond budget ran to completion instead of being stopped. Node's vm module has no default timeout, so renaming the key does not change the budget, it removes it.
Does renaming re-enable eval inside a sandbox?
Yes, from either direction. Renaming the code-generation policy object and renaming the two flags inside it produced the same outcome: eval inside the sandbox went from blocked to allowed. Node applies its default policy, which permits code generation, whenever it does not find the option.
Why did the sandbox return before the plugin finished?
Because the microtask option's name was renamed. Configured to drain microtasks after evaluation, the call returned with the plugin's promise callback already recorded; renamed, it returned with the callback still pending. The value is not wrong, it is early, which turns a deterministic sequence into a race.
What happens to sandbox stack traces?
They lose their attribution. Renaming the script filename and line offset replaced a frame naming the tenant and plugin file with node's generic sandbox placeholder at line one. The failure is unchanged; the ability to say whose plugin caused it is not.
Does obfuscation rename the untrusted code running inside the sandbox?
No. Plugin source held as a string is not a rename site, so sandboxed code keeps its names. The host that launches it does not. That asymmetry is worth knowing wherever compiled code and code held as text meet, including plugin APIs, templates and expression evaluators.
How do we test a sandbox on a protected build?
Exercise the controls. Run a plugin that never returns and require the call to throw; run a plugin that calls eval and require it to fail. Asserting that the options object contains a timeout passes on both sides of a rename and proves nothing.
Related reading