Node.js Code Injection: eval(), Function Constructor, and vm Module Escapes

Server-side JavaScript injection in Node.js occurs when user input reaches eval(), the Function constructor, or vm.runInContext() without sanitisation. This guide covers the attack mechanics, vm module escape techniques, and the safe alternatives that eliminate the vulnerability class.

Server-side JavaScript injection (SSJI) is what happens when a Node.js application evaluates user-controlled strings as code. It’s functionally equivalent to eval injection in PHP or expression injection in Java template engines — user input transitions from data to instruction. The result is full remote code execution with the privileges of the Node.js process.

The vulnerability appears in predictable forms: direct eval() calls with user input, the Function constructor used as an alternative eval, and vm.runInContext() calls that developers assume are sandboxed. None of these are safe for untrusted input. The vm module is the most dangerous misconception: it is explicitly documented by Node.js as not a security boundary.

Direct eval() Injection

The most straightforward case: user input passed to eval().

// Vulnerable — eval executes any JavaScript in user input
app.get('/calculate', (req, res) => {
  const expression = req.query.expr;
  const result = eval(expression);  // Never do this
  res.json({ result });
});

An attacker sends:

GET /calculate?expr=require('child_process').execSync('id').toString()

This executes the id command and returns its output. Any shell command, file read, network request, or process spawn is available. The require function is in scope within eval(), giving access to the full Node.js module system.

Function Constructor Injection

The Function constructor builds and executes a function from a string — it is eval by another name. It appears in codebases where developers know to avoid eval but reach for Function as an alternative.

// Vulnerable — identical risk to eval()
app.post('/template', (req, res) => {
  const userTemplate = req.body.template;
  const fn = new Function('data', `return \`${userTemplate}\``);
  const output = fn({ name: 'World' });
  res.send(output);
});

Attacker payload in template:

${require('child_process').execSync('cat /etc/passwd').toString()}

The template literal is evaluated by Function. Any JavaScript accessible in the global scope runs. Because Function evaluates in the global scope rather than the local scope, closures don’t restrict access.

vm Module: Not a Security Sandbox

The Node.js vm module provides a way to compile and run code in a separate V8 context. It is widely misunderstood as a security sandbox. The Node.js documentation explicitly states otherwise: “The vm module is not a security mechanism. Do not use it to run untrusted code.”

// DANGEROUSLY MISLEADING — vm.runInNewContext is not a sandbox
const vm = require('vm');

app.post('/eval', (req, res) => {
  const code = req.body.code;
  const sandbox = { result: null };
  vm.runInNewContext(code, sandbox, { timeout: 1000 });
  res.json({ result: sandbox.result });
});

The escape vector uses the prototype chain to access the outer Node.js context:

// Attacker payload — escapes the vm sandbox
this.constructor.constructor('return process')().exit(1)

// Alternative escape via constructor chain
[].constructor.constructor('return process')().env

// Or via __proto__
({}).constructor.constructor('return require("child_process")')()
  .execSync('id').toString()

this.constructor inside the sandboxed code refers to the Object constructor from the outer context. Object.constructor is the Function constructor. Calling Function('return process')() from within the sandbox returns the outer Node.js process object. At that point the sandbox is escaped and full process control is available.

The timeout option prevents infinite loops but does not prevent breakout.

Template Engine Injection

Several Node.js template engines allow arbitrary JavaScript execution in their templates. When templates are built from user input, this becomes SSJI.

EJS (Embedded JavaScript Templates):

// Vulnerable — user-controlled template string
const ejs = require('ejs');
const template = req.query.template;
const rendered = ejs.render(template, { user: currentUser });

EJS allows <%= ... %> execution. Payload:

<%= require('child_process').execSync('id') %>

Handlebars — less commonly exploitable but prototype pollution risk:

Handlebars templates that accept user-defined partials or helpers have historically been vulnerable to prototype pollution enabling template injection.

Pug/Jade:

const pug = require('pug');
// If the template string is user-controlled:
const fn = pug.compile(req.body.template);

Pug templates run in Node.js with access to the full module system unless explicitly sandboxed.

Safe Alternatives

For mathematical expressions: use a purpose-built parser

// Use math.js or expr-eval — no code execution
const { evaluate } = require('mathjs');

app.get('/calculate', (req, res) => {
  try {
    const result = evaluate(req.query.expr);  // Limited to math operations
    res.json({ result });
  } catch (err) {
    res.status(400).json({ error: 'Invalid expression' });
  }
});

mathjs.evaluate() supports arithmetic, trigonometry, algebra, and unit conversion. It does not execute arbitrary JavaScript.

For JSON parsing: use JSON.parse()

A very common source of eval() usage is lazy JSON parsing. Replace with JSON.parse():

// Wrong
const data = eval('(' + req.body.json + ')');

// Right
let data;
try {
  data = JSON.parse(req.body.json);
} catch {
  return res.status(400).send('Invalid JSON');
}

For true code sandboxing: use isolated-vm

isolated-vm by laverdet provides a proper V8 isolate — a genuine separate V8 heap with no prototype chain connection to the host context:

const ivm = require('isolated-vm');

const isolate = new ivm.Isolate({ memoryLimit: 128 });
const context = await isolate.createContext();

// Explicitly add only what you want available
await context.global.set('safeInput', new ivm.ExternalCopy({ value: 42 }).copyInto());

const result = await isolate.compileScript(untrustedCode)
  .then(script => script.run(context, { timeout: 100 }));

Unlike vm, isolated-vm has separate memory management and the host’s prototype chain is genuinely unreachable. This is the correct tool when you must execute untrusted code.

For template rendering with user-controlled data: use templates, not strings

Pass data into templates, never pass user input as the template itself:

// Wrong — user controls the template
const output = ejs.render(req.body.template, data);

// Right — user only controls data, template is static
const template = fs.readFileSync('./views/report.ejs', 'utf8');
const output = ejs.render(template, { userInput: req.body.value });

Detection and Review

Static analysis tools detect most direct eval() usage. Add to your security review checklist:

grep -rn "eval(" src/
grep -rn "new Function(" src/
grep -rn "vm\.run" src/
grep -rn "\.render(req\." src/
grep -rn "\.compile(req\." src/

For dependency scanning, packages that transitively call eval() on data that flows from user input are a supply chain risk. Semgrep rules for JavaScript SSJI cover the common patterns and can be integrated into CI.

The root principle is simple: user input is data. Data doesn’t get executed. Any code path where that boundary is crossed is a vulnerability — regardless of what layer of indirection sits between the input and the execution.