Running an expression a user typed, without handing over the server
new Function gives the user your permissions. Node's vm module looks like a sandbox and escapes in one line. The safe option is a grammar small enough that there is no path to the host at all.
Do not evaluate it as JavaScript. Parse it with a grammar you wrote, or run it in a separate process with real limits. new Function and node:vm both look like solutions and neither is a boundary.
This comes straight out of building a flow builder, which ends with advice to start with "a tiny safe evaluator". This is that evaluator, and why the two obvious shortcuts are not options.
new Function is not a sandbox, it is your code
The user types amount > 1000 into a condition box. The shortest thing that works:
const fn = new Function(...Object.keys(data), `return (${expr})`)
return fn(...Object.values(data))
It runs. It also runs everything else, because the expression is now part of your program:
amount > 1000 → true the intended use
process.env.HOME → /home/you your environment
process.constructor
.constructor("return process")().platform
→ darwin arbitrary host access
Those are real outputs from that four-line implementation. process.env holds your database URL and your API keys. The user did not break anything; you handed them a REPL with your permissions.
node:vm is not one either, and says so
The natural next step is Node's built-in vm, which creates a separate context. It genuinely isolates variables. It does not isolate reachability:
import vm from 'node:vm'
const ctx = vm.createContext({ amount: 4200 })
vm.runInContext(
'this.constructor.constructor("return process")().env.HOME', ctx, { timeout: 500 })
// → /home/you
One line, and we are back in the host realm. The object inside the context has a constructor; that constructor is a Function from the host; calling it compiles new code outside the sandbox.
This is not a bug being fixed. Node's own documentation states that vm is not a security mechanism and should not be used to run untrusted code. It exists to separate scopes, not to contain adversaries.
If you want a genuine JavaScript isolate, that is isolated-vm or a separate process — not vm.
What actually works: a grammar with nowhere to go
The insight that makes this easy: your users do not need JavaScript. They need comparisons, some logic, and access to the data in front of them. That is a grammar you can write in fifty lines, and it is safe for a structural reason — there is no code path from it to anything else.
const OPS = {
'>': (a, b) => a > b, '<': (a, b) => a < b,
'>=': (a, b) => a >= b, '<=': (a, b) => a <= b,
'==': (a, b) => a === b, '!=': (a, b) => a !== b,
contains: (a, b) => String(a).includes(String(b)),
}
function tokenize(src) {
const re = /\s*(>=|<=|==|!=|>|<|\band\b|\bor\b|\bcontains\b|\(|\)|"[^"]*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/y
const out = []
let m, pos = 0
// A failed sticky exec resets lastIndex to 0, so remember how far we got.
while ((m = re.exec(src))) { out.push(m[1]); pos = re.lastIndex }
if (pos < src.trimEnd().length) throw new Error(`unexpected input at ${pos}`)
return out
}
Field lookup is the part that carries the safety:
const value = (tok) => {
if (/^-?\d/.test(tok)) return Number(tok)
if (tok.startsWith('"')) return tok.slice(1, -1)
return tok.split('.').reduce((o, k) =>
(o !== null && typeof o === 'object' && Object.hasOwn(o, k)) ? o[k] : undefined, data)
}
Object.hasOwn is doing real work. Without it, constructor, __proto__ and toString all resolve to something, and you have reintroduced the prototype chain as an attack surface. With it, a name that is not literally a key in the run's own data returns undefined.
Results from the finished evaluator:
amount > 1000 → true
amount > 1000 and customer.tier == "gold" → true
note contains "renewal" → true
amount < 100 → false
process.env.HOME → false
this.constructor.constructor("return process")().platform
→ refused: unexpected input at 48
Two different rejections, and the difference is instructive. The constructor expression is a parse error — ( after a name is not in the grammar, so it cannot even be represented. process.env.HOME parses fine as a field lookup, and simply finds nothing, because process is not a key in the run's data. It is not blocked; it is unreachable.
That is the property to aim for. Blocklists are guesses about what an attacker will try. A grammar that cannot express the dangerous thing does not need to guess.
When you genuinely need real code
Some products must run user-supplied JavaScript or Python — a "run this script" node is a reasonable feature. Then the boundary has to be an operating-system one:
- A separate process, with a hard timeout, a memory cap, and no credentials in its environment. Never the process holding your database pool.
- A container per execution — this is what CI systems do, because it is the only model that survives someone deliberately trying.
- A real isolate such as
isolated-vm, if you want in-process speed and accept the operational weight.
And regardless of which: a wall-clock timeout, a memory limit, and no network unless the feature requires it. while(true){} is not an attack, it is a typo, and it should cost one worker for two seconds rather than take out a queue.
Check it yourself
Watch vm escape. Thirty seconds, and it makes the point better than any argument:
// save as vmtest.mjs
import vm from 'node:vm'
const ctx = vm.createContext({ amount: 4200 })
console.log(vm.runInContext(
'this.constructor.constructor("return process")().platform', ctx, { timeout: 500 }))
node vmtest.mjs # prints your platform, from inside the "sandbox"
If that surprises you, it is worth checking whether anything you run today evaluates a user-supplied string.
The code
Runnable, and CI keeps it that way: CSTSolution/examples/safe-expression-eval — the grammar and its tests.
git clone https://github.com/CSTSolution/examples
cd examples/safe-expression-eval
Where this goes next
This is how conditions are evaluated in Workflow Builder — a small grammar over the run's own data, with anything heavier pushed out to a process that holds no credentials.
Earlier in this series: the flow builder data model, retries and exactly-once, and verifying webhook signatures.