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.

User input, a boundary, and the host it cannot reach

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.

eval and node:vm are not boundaries; a restricted grammar or a separate process is

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.