Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix code injection in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Do not turn a request string into code. eval, new Function and node:vm all run it, and node:vm is not a sandbox. Parse the formula with a fixed grammar (numbers, the allowlisted field names and the arithmetic operators) and evaluate that over the row, so there is no function, property or global to reach.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1 (node:vm built in)
  • Framework Express 5.3.0
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks4 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. Find every place a request value is evaluated: eval, new Function, the vm module, setTimeout/setInterval with a string, or a library that compiles expressions. Treat each as remote code execution.
  2. Decide the small grammar the feature actually needs. Here it is arithmetic over three named fields, so the grammar is numbers, those field names, + - * / and parentheses.
  3. Parse that grammar yourself (a short recursive-descent parser) or with a maintained library whose evaluator is data-only, and evaluate the parsed form over the row. An identifier resolves to a field value, never to a variable, function or global.
  4. Reject anything outside the grammar with a 400: an unknown name, a property access, a call. Never a 500, and never a fallback to eval.
  5. Keep the result bounded and typed: this endpoint returns a finite number or a 400.
  6. Add regression tests that send process.env reads, prototype-chain escapes and global access, and assert the secret never reaches the response.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export function evaluate(formula, row) {
  // eslint-disable-next-line no-new-func -- this is the vulnerability the card shows
  const run = new Function('row', `with (row) { return (${formula}); }`);
  return run(row);
}

With IMPL=vulnerable, 2 of 7 tests fail (2 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
const FIELDS = new Set(['price', 'quantity', 'discount']);

// token: a number, a name, one of + - * / ( ), or end.
function* tokenize(src) {
  const pattern = /\s*([0-9]*\.?[0-9]+|[A-Za-z_][A-Za-z0-9_]*|[-+*/()])/y;
  let index = 0;
  while (index < src.length) {
    pattern.lastIndex = index;
    const match = pattern.exec(src);
    if (!match || match.index !== index) {
      throw new Error(`unexpected character at position ${index}`);
    }
    index = pattern.lastIndex;
    yield match[1];
  }
  yield null; // end
}

export function evaluate(formula, row) {
  const tokens = tokenize(formula);
  let current = tokens.next().value;
  const next = () => (current = tokens.next().value);

  // expr := term (('+' | '-') term)*
  function expr() {
    let value = term();
    while (current === '+' || current === '-') {
      const op = current;
      next();
      const rhs = term();
      value = op === '+' ? value + rhs : value - rhs;
    }
    return value;
  }
  // term := factor (('*' | '/') factor)*
  function term() {
    let value = factor();
    while (current === '*' || current === '/') {
      const op = current;
      next();
      const rhs = factor();
      value = op === '*' ? value * rhs : value / rhs;
    }
    return value;
  }
  // factor := '-' factor | '(' expr ')' | number | field
  function factor() {
    if (current === '-') {
      next();
      return -factor();
    }
    if (current === '(') {
      next();
      const value = expr();
      if (current !== ')') throw new Error('missing closing parenthesis');
      next();
      return value;
    }
    const token = current;
    if (token === null) throw new Error('unexpected end of formula');
    if (/^[0-9]/.test(token)) {
      next();
      return Number(token);
    }
    if (/^[A-Za-z_]/.test(token)) {
      if (!FIELDS.has(token)) throw new Error(`unknown field "${token}"`);
      next();
      return row[token];
    }
    throw new Error(`unexpected token "${token}"`);
  }

  const value = expr();
  if (current !== null) throw new Error(`unexpected token "${current}"`);
  return value;
}

With IMPL=fixed, 7 of 7 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
export function evaluate(formula, row) {
  // The sandbox holds the fields and the row object the formula computes over.
  const sandbox = { price: row.price, quantity: row.quantity, discount: row.discount, data: row };
  // "A short timeout and a bare context keep it safe." It does not:
  // data.constructor.constructor("return process")() reaches the real process.
  return vm.runInNewContext(formula, sandbox, { timeout: 100 });
}

With IMPL=wrong, 1 of 7 tests fail (1 exploit test).

Route: a formula is data, not a 500 risk

app.jsJavaScript
// The caller sends a pricing formula and a row of numbers. impl.evaluate returns
// a number or throws; a formula it will not compute is a 400, never a 500, and
// the formula is never run as code.
app.post('/quote', (req, res) => {
  const formula = req.body?.formula;
  if (typeof formula !== 'string' || formula.length === 0 || formula.length > 500) {
    return res.status(400).json({ error: 'formula must be a non-empty string under 500 chars' });
  }
  try {
    const result = impl.evaluate(formula, toRow(req.body?.row ?? {}));
    if (typeof result !== 'number' || !Number.isFinite(result)) {
      return res.status(400).json({ error: 'formula did not produce a finite number' });
    }
    res.json({ result });
  } catch (err) {
    res.status(400).json({ error: `cannot evaluate formula: ${err.message}` });
  }
});

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/quote.test.jsJavaScript
describe(`exploit (IMPL=${implName})`, () => {
  // No formula may read the server's environment or reach host objects. The secret
  // is a number so the proof is unambiguous: if it reaches the result, code ran
  // (the same vector runs child_process). A safe evaluator rejects these with 400.
  async function assertNoCodeRan(formula) {
    const res = await quote(formula);
    assert.ok(
      !res.body.includes(SECRET),
      `formula ran as code and leaked the secret env var: ${res.body}`,
    );
    assert.equal(res.status, 400, `expected 400 for a non-arithmetic formula, got ${res.status} ${res.body}`);
  }

  test('a formula cannot read an environment variable', async () => {
    await assertNoCodeRan('+process.env.FIXCARD_SECRET');
  });

  test('a formula cannot reach a host object through its prototype chain', async () => {
    await assertNoCodeRan('+data.constructor.constructor("return process.env.FIXCARD_SECRET")()');
  });

  test('a formula cannot call a global', async () => {
    await assertNoCodeRan('+globalThis.process.env.FIXCARD_SECRET');
  });
});

Pitfalls

  • node:vm is not a sandbox. The Node.js docs say so plainly. A fresh context and a timeout do not help: any host object you place in the context (here the row the formula computes over) exposes the host Function constructor through data.constructor.constructor, which runs code in the main realm. That is this card's wrong implementation.
  • new Function and eval run in the module's scope, so the formula reaches process, require and globalThis even without an explicit import.
  • A denylist of words (process, require, constructor) is not a grammar. Attackers reconstruct names with string concatenation, bracket access and prototype walks; the allowed grammar must be defined by what you accept, not by what you reject.
  • Reaching for a sandboxed-JS library (vm2, isolated-vm) to run untrusted code adds a maintenance and escape risk for a problem that a 20-line parser solves here. Use real isolation only when you genuinely must run user-authored code.
  • Returning the raw evaluation result can leak internal values even when the number looks harmless; validate the type and range before responding.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs code-injection/node-express does this for you).
  2. Against your own endpoint, send formulas that read process.env, walk an object's constructor chain, and touch globalThis, with a known secret in the environment. A safe evaluator answers 400 and the secret never appears; if it does, the string ran as code.
  3. The vulnerable code fails on process.env and globalThis reads; the wrong code (node:vm) fails on the prototype-chain escape. Do not rely on a scanner: Semgrep's JavaScript rules flag eval but not new Function or node:vm, so the regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix code injection in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.lang.security.detect-eval-with-expression.detect-eval-with-expression0 findings on impl/*.js and app.js, here and across the p/javascript and p/nodejs packs. The rule matches eval(<expression>); it does not match new Function(...) (the vulnerable renderer) or vm.runInNewContext(...) (the wrong one), so neither is flagged.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0
CodeQLjs/code-injectionModels eval, the Function constructor and the node:vm functions (runInContext/runInNewContext/runInThisContext) as code-execution sinks and HTTP request data as a source, so it is expected to flag the vulnerable new Function and the wrong vm.runInNewContext when the handler and the evaluator share a flow it can follow. The fixed parser has no sink.Checked: github/codeql main; not executed

No Semgrep CE rule flags new Function or node:vm as a code-injection sink; the registry's JavaScript code-injection rule targets eval. Node's documentation states the vm module is not a security mechanism, which is the point of the wrong implementation. The regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1 (node:vm built in)
FrameworkExpress 5.3.0
express5.3.0
node:vm (built in)Node.js 26.11.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the code-injection/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed FIXCARD_SECRET=730199138246 npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable FIXCARD_SECRET=730199138246 npm test --silent

# Expected: a test fails
IMPL=wrong FIXCARD_SECRET=730199138246 npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs code-injection/node-express runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
Matrix
fixed: 7 of 7 tests pass; vulnerable: 2 of 7 tests fail (2 exploit tests); wrong: 1 of 7 tests fail (1 exploit test)
Test results
IMPL=fixed 7/7 pass; IMPL=vulnerable 5/7 pass, 2 exploit fail (reading process.env and globalThis.process as a formula leaks the secret number); IMPL=wrong 6/7 pass, 1 exploit fail (the node:vm context is escaped through data.constructor.constructor, leaking the secret). FIXCARD_SECRET is a number so the leak is unambiguous; the same vector runs child_process.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Top 10:2025 A05 Injection
  2. CWE-94: Improper Control of Generation of Code ('Code Injection')
  3. CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
  4. OWASP Code Injection
  5. Node.js v26: VM (executing JavaScript) — "The vm module is not a security mechanism"
  6. MDN: never use eval()!
  7. MDN: Function() constructor

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.