expr-eval-nextgen
v2.4.3
Published
Hardened fork of expr-eval with fixes for CVE-2025-12735, CVE-2025-13204 and CVE-2026-12866; evaluate() up to 9x faster
Maintainers
Readme
expr-eval-nextgen
A hardened, actively maintained continuation of expr-eval, published to npm as
expr-eval-nextgen.Breaking change vs upstream:
toJSFunction()is removed (it compiled expressions withnew Function()— CVE-2026-12866). Useevaluate(). Security fixes below, spreadsheet-grade equality options, and an evaluate() path that is up to ~9x faster than upstream — while default API, syntax, and semantics stay identical to upstream 2.0.2.
Credits
This library exists because of Matthew Crumley
(silentmatt), the author of
expr-eval, who in turn ported it
from ndef.parser by Raphael Graf. The parser, the stack-machine evaluator,
and the original test suite are his work, used here under the MIT License
(see LICENSE.txt, which retains his copyright notice permanently). All
credit for the core engine belongs to him.
This continuation adds the security hardening described below, the benchmark harness, and ongoing maintenance — nothing in it diminishes the upstream project; if upstream resumes releasing, this package will track it wherever practical.
Hardened fork security model
This fork closes every advisory filed against upstream expr-eval ≤ 2.0.2
without the performance or compatibility regressions of the other patched
forks (both expr-eval-fork and safe-expr-eval break or silently ignore
function-valued scope entries — a core expr-eval pattern this fork preserves).
| Advisory | Vector | Fix in this fork |
|---|---|---|
| GHSA-8gw3-rxh4-v6jx / CVE-2025-13204 | Prototype pollution via __proto__/prototype/constructor in expressions | Parse-time unsafe-name ban for variable, member, and function-parameter names — rejected once at parse(), zero cost at evaluate() (also supersedes upstream master's per-evaluation regex check, making this fork faster than master) |
| GHSA-jc85-fpwf-qm7x / CVE-2025-12735 | Crafted variables object passed to evaluate() | Scope gate rejects scope objects carrying own __proto__/prototype/constructor keys; runs once per unique scope object (WeakMap), so per-combo scopes reused across thousands of cells pay nothing |
| GHSA-q9v2-7m5w-4693 / CVE-2026-12866 | Arbitrary code execution via toJSFunction() (new Function + with) | toJSFunction() is disabled and always throws — evaluate() is the only evaluation path |
Behavioral differences from upstream
Expression.prototype.toJSFunction()throws instead of compiling to native JavaScript. Useevaluate().nullis a literal, liketrue/false(upstream issue #228): compare withvalue != nullfreely.- Optional
looseEqualityparser mode (upstream issue #110):new Parser({ looseEquality: true })compares a number and a numeric-looking string numerically ("3" == 3is true), matching spreadsheet semantics; non-numeric strings never equal numbers; string-to-string stays strict. Off by default — the default==is unchanged, strict on types. - Optional
protectScope: expressions that assign (=) or define inline functions evaluate against a shallow clone of the scope, so writes never leak into the caller's object — shared scopes cannot be poisoned by a formula likeMIN = 99. Read-your-writes still works within the call; non-writing expressions pay nothing. Off by default (legacy writes propagate). Cost on assigning formulas: ~2-5us per evaluate (one shallow clone); reusing scope objects keeps the security gate itself free. variables()no longer reports scope-called function names (upstream issue #7):parse('helper(x) + y').variables()is['x', 'y'].symbols()still reports everything.- Parse errors expose
err.lineanderr.column(upstream issue #247); message formats are unchanged. - Optional
equalityEpsilon(upstream issue #10):new Parser({ equalityEpsilon: 1e-12 })compares numbers with relative tolerance, spreadsheet-style, so0.1 + 0.2 == 0.3is true and accumulated float rounding does not flip comparisons. Tolerance is relative, so it scales with magnitude; real differences (1.0000000001 == 1) stay false. Composes withlooseEquality(coerce, then tolerate). Off by default.
Sandbox limits — read this before trusting it with hostile input
What this fork guarantees:
- No prototype pollution:
__proto__,prototype, andconstructorare rejected as variable, member, and parameter names at parse time, and scope objects carrying those keys are rejected at evaluate time. - No code generation:
evaluate()is the only evaluation path; there is noeval, nonew Function, nowith.
What it does not guarantee:
- Function-valued scope entries are callable and run with full host privileges. The sandbox constrains the expression, not the functions you hand it. If your scope carries anything dangerous, an untrusted formula can call it. Passing a curated function library is the supported pattern; passing your whole application object is not.
- No built-in resource limits. Expression length, parse time, and
evaluation time are unbounded; if input is hostile, cap input length and
time-box evaluation in the caller. (Recursion in inline function
definitions, e.g.
f(x) = f(x), will stack-overflow.) - Member access (
a.b), when enabled, can read (not call) properties of scope objects you provide.
This fork closes the known CVEs against upstream and hardens the expression surface; it is not a full isolation boundary for hostile function libraries. An independent review is welcomed before you stake your security story on it.
Numeric precision
Values are IEEE 754 doubles, like Excel and Anaplan (upstream issue #10). Practical implications for formula authors:
0.1 + 0.2is0.30000000000000004; useroundTo(x, 10)when a formula compares or displays sums of inexact decimals.- Integers are exact up to 2^53 (~9.0e15); beyond that, precision is lost.
- Round at display/save boundaries, and enable
equalityEpsilonwhen formulas compare computed floats with==.
Other differences from upstream
- Parsing rejects the identifiers
__proto__,prototype, andconstructoranywhere a name is bound (variable reference, member access, function parameter). These were never legitimate formula identifiers. - New export:
validateScope(values)— pre-validate a scope object when you construct it to skip the (already amortized) gate on the evaluate() hot path. package.jsonhas a properexportsmap with an ESM entry point (upstream issue #280).
Performance
Since 2.2.0, expressions are closure-compiled: each expression is compiled
once into a tree of nested closures and evaluated as plain function calls —
no dispatch switch, no value stack, no repeated hash lookups. On Node 24,
pre-parsed evaluate() vs upstream 2.0.2:
- function-dense formula with function-valued scope: ~9-12x faster
- variable-heavy arithmetic: ~6x
- equality-heavy formulas: ~13x
- small expressions (reused scope): ~10x
- constant expressions fold at compile time: ~100x+
Since 2.3.0 the compiler tags operands (literal / scope variable / dynamic),
inlines scope reads and constants into their consumers, specializes the
dominant fn(var) call shapes, and constant-folds pure literal ops. (For
comparison, mathjs measured ~1.3x slower than upstream on the same
workload, and the other patched forks ~1.2-1.7x slower.) Run
node bench/evaluate.mjs for the comparison harness.
Installing
npm install expr-eval-nextgenconst { Parser } = require('expr-eval-nextgen');
// or: import { Parser } from 'expr-eval-nextgen';Description
Parses and evaluates mathematical expressions. It's a safer and more
math-oriented alternative to using JavaScript’s eval function for mathematical
expressions.
It has built-in support for common math operators and functions. Additionally,
you can add your own JavaScript functions. Expressions can be evaluated
directly (in this fork, toJSFunction() code generation is disabled for
security — see above).
Installation
npm install expr-eval-nextgenBasic Usage
const Parser = require('expr-eval-nextgen').Parser;
const parser = new Parser();
let expr = parser.parse('2 * x + 1');
console.log(expr.evaluate({ x: 3 })); // 7
// or
Parser.evaluate('6 * x', { x: 7 }) // 42Documentation
Parser
Parser is the main class in the library. It has as single parse method, and
"static" methods for parsing and evaluating expressions.
Parser()
Constructs a new Parser instance.
The constructor takes an optional options parameter that allows you to enable or disable operators.
For example, the following will create a Parser that does not allow comparison or logical operators, but does allow in:
const parser = new Parser({
operators: {
// These default to true, but are included to be explicit
add: true,
concatenate: true,
conditional: true,
divide: true,
factorial: true,
multiply: true,
power: true,
remainder: true,
subtract: true,
// Disable and, or, not, <, ==, !=, etc.
logical: false,
comparison: false,
// Disable 'in' and = operators
'in': false,
assignment: false
}
});parse(expression: string)
Convert a mathematical expression into an Expression object.
Parser.parse(expression: string)
Static equivalent of new Parser().parse(expression).
Parser.evaluate(expression: string, variables?: object)
Parse and immediately evaluate an expression using the values and functions from
the variables object.
Parser.evaluate(expr, vars) is equivalent to calling Parser.parse(expr).evaluate(vars).
Expression
Parser.parse(str) returns an Expression object. Expressions are similar to
JavaScript functions, i.e. they can be "called" with variables bound to
passed-in values.
evaluate(variables?: object)
Evaluate the expression, with variables bound to the values in {variables}. Each
variable in the expression is bound to the corresponding member of the
variables object. If there are unbound variables, evaluate will throw an
exception.
js> expr = Parser.parse("2 ^ x");
(2^x)
js> expr.evaluate({ x: 3 });
8substitute(variable: string, expression: Expression | string | number)
Create a new Expression with the specified variable replaced with another
expression. This is similar to function composition. If expression is a string
or number, it will be parsed into an Expression.
js> expr = Parser.parse("2 * x + 1");
((2*x)+1)
js> expr.substitute("x", "4 * x");
((2*(4*x))+1)
js> expr2.evaluate({ x: 3 });
25simplify(variables: object)
Simplify constant sub-expressions and replace variable references with literal values. This is basically a partial evaluation, that does as much of the calculation as it can with the provided variables. Function calls are not evaluated (except the built-in operator functions), since they may not be deterministic.
Simplify is pretty simple. For example, it doesn’t know that addition and
multiplication are associative, so ((2*(4*x))+1) from the previous example
cannot be simplified unless you provide a value for x. 2*4*x+1 can however,
because it’s parsed as (((2*4)*x)+1), so the (2*4) sub-expression will be
replaced with "8", resulting in ((8*x)+1).
js> expr = Parser.parse("x * (y * atan(1))").simplify({ y: 4 });
(x*3.141592653589793)
js> expr.evaluate({ x: 2 });
6.283185307179586variables(options?: object)
Get an array of the unbound variables in the expression. Names that only
appear as called functions (helper(x)) are not included; use symbols()
for everything.
js> expr = Parser.parse("x * (y * atan(1))");
(x*(y*atan(1)))
js> expr.variables();
x,y
js> expr.simplify({ y: 4 }).variables();
xBy default, variables will return "top-level" objects, so for example, Parser.parse(x.y.z).variables() returns ['x']. If you want to get the whole chain of object members, you can call it with { withMembers: true }. So Parser.parse(x.y.z).variables({ withMembers: true }) would return ['x.y.z'].
symbols(options?: object)
Get an array of variables, including any built-in functions used in the expression.
js> expr = Parser.parse("min(x, y, z)");
(min(x, y, z))
js> expr.symbols();
min,x,y,z
js> expr.simplify({ y: 4, z: 5 }).symbols();
min,xLike variables, symbols accepts an option argument { withMembers: true } to include object members.
toString()
Convert the expression to a string. toString() surrounds every sub-expression
with parentheses (except literal values, variables, and function calls), so
it’s useful for debugging precedence errors.
toJSFunction (disabled in this fork)
Removed for security (CVE-2026-12866 / GHSA-q9v2-7m5w-4693): this method
compiled expressions into native JavaScript via new Function(), which allowed
crafted variables to execute arbitrary code. It now always throws. Use
evaluate() — since 2.2.0 it is closure-compiled and several times faster than
upstream's interpreter anyway.
Expression Syntax
The parser accepts a pretty basic grammar. It's similar to normal JavaScript
expressions, but is more math-oriented. For example, the ^ operator is
exponentiation, not xor.
Operator Precedence
Operator | Associativity | Description :----------------------- | :------------ | :---------- (...) | None | Grouping f(), x.y, a[i] | Left | Function call, property access, array indexing ! | Left | Factorial ^ | Right | Exponentiation +, -, not, sqrt, etc. | Right | Unary prefix operators (see below for the full list) *, /, % | Left | Multiplication, division, remainder +, -, || | Left | Addition, subtraction, array/list concatenation ==, !=, >=, <=, >, <, in | Left | Equals, not equals, etc. "in" means "is the left operand included in the right array operand?" and | Left | Logical AND or | Left | Logical OR x ? y : z | Right | Ternary conditional (if x then y else z) = | Right | Variable assignment ; | Left | Expression separator
const parser = new Parser({
operators: {
'in': true,
'assignment': true
}
});
// Now parser supports 'x in array' and 'y = 2*x' expressionsUnary operators
The parser has several built-in "functions" that are actually unary operators.
The primary difference between these and functions are that they can only accept
exactly one argument, and parentheses are optional. With parentheses, they have
the same precedence as function calls, but without parentheses, they keep their
normal precedence (just below ^). For example, sin(x)^2 is equivalent to
(sin x)^2, and sin x^2 is equivalent to sin(x^2).
The unary + and - operators are an exception, and always have their normal
precedence.
Operator | Description :------- | :---------- -x | Negation +x | Unary plus. This converts it's operand to a number, but has no other effect. x! | Factorial (x * (x-1) * (x-2) * … * 2 * 1). gamma(x + 1) for non-integers. abs x | Absolute value (magnitude) of x acos x | Arc cosine of x (in radians) acosh x | Hyperbolic arc cosine of x (in radians) asin x | Arc sine of x (in radians) asinh x | Hyperbolic arc sine of x (in radians) atan x | Arc tangent of x (in radians) atanh x | Hyperbolic arc tangent of x (in radians) cbrt x | Cube root of x ceil x | Ceiling of x — the smallest integer that’s >= x cos x | Cosine of x (x is in radians) cosh x | Hyperbolic cosine of x (x is in radians) exp x | e^x (exponential/antilogarithm function with base e) expm1 x | e^x - 1 floor x | Floor of x — the largest integer that’s <= x length x | String or array length of x ln x | Natural logarithm of x log x | Natural logarithm of x (synonym for ln, not base-10) log10 x | Base-10 logarithm of x log2 x | Base-2 logarithm of x log1p x | Natural logarithm of (1 + x) not x | Logical NOT operator round x | X, rounded to the nearest integer, using "grade-school rounding" sign x | Sign of x (-1, 0, or 1 for negative, zero, or positive respectively) sin x | Sine of x (x is in radians) sinh x | Hyperbolic sine of x (x is in radians) sqrt x | Square root of x. Result is NaN (Not a Number) if x is negative. tan x | Tangent of x (x is in radians) tanh x | Hyperbolic tangent of x (x is in radians) trunc x | Integral part of a X, looks like floor(x) unless for negative number
Pre-defined functions
Besides the "operator" functions, there are several pre-defined functions. You can provide your own, by binding variables to normal JavaScript functions. These are not evaluated by simplify.
Function | Description
:------------ | :----------
random(n) | Get a random number in the range [0, n). If n is zero, or not provided, it defaults to 1.
fac(n) | n! (factorial of n: "n * (n-1) * (n-2) * … * 2 * 1") Deprecated. Use the ! operator instead.
min(a,b,…) | Get the smallest (minimum) number in the list.
max(a,b,…) | Get the largest (maximum) number in the list.
hypot(a,b) | Hypotenuse, i.e. the square root of the sum of squares of its arguments.
pyt(a, b) | Alias for hypot.
pow(x, y) | Equivalent to x^y. For consistency with JavaScript's Math object.
atan2(y, x) | Arc tangent of x/y. i.e. the angle between (0, 0) and (x, y) in radians.
roundTo(x, n) | Rounds x to n places after the decimal point.
map(f, a) | Array map: Pass each element of a the function f, and return an array of the results.
fold(f, y, a) | Array fold: Fold/reduce array a into a single value, y by setting y = f(y, x, index) for each element x of the array.
filter(f, a) | Array filter: Return an array containing only the values from a where f(x, index) is true.
indexOf(x, a) | Return the first index of string or array a matching the value x, or -1 if not found.
join(sep, a) | Concatenate the elements of a, separated by sep.
if(c, a, b) | Function form of c ? a : b. Note: This always evaluates both a and b, regardless of whether c is true or not. Use c ? a : b instead if there are side effects, or if evaluating the branches could be expensive.
Array literals
Arrays can be created by including the elements inside square [] brackets, separated by commas. For example:
[ 1, 2, 3, 2+2, 10/2, 3! ]Function definitions
You can define functions using the syntax name(params) = expression. When it's evaluated, the name will be added to the passed in scope as a function. You can call it later in the expression, or make it available to other expressions by re-using the same scope object. Functions can support multiple parameters, separated by commas. (With the protectScope option enabled, definitions stay inside the evaluation and do not leak into the scope.)
Examples:
square(x) = x*x
add(a, b) = a + b
factorial(x) = x < 2 ? 1 : x * factorial(x - 1)Custom JavaScript functions
If you need additional functions that aren't supported out of the box, you can easily add them in your own code. Instances of the Parser class have a property called functions that's simply an object with all the functions that are in scope. You can add, replace, or delete any of the properties to customize what's available in the expressions. For example:
const parser = new Parser();
// Add a new function
parser.functions.customAddFunction = function (arg1, arg2) {
return arg1 + arg2;
};
// Remove the factorial function
delete parser.functions.fac;
parser.evaluate('customAddFunction(2, 4) == 6'); // true
//parser.evaluate('fac(3)'); // This will failConstants
The parser also includes a number of pre-defined constants that can be used in expressions. These are shown in the table below:
Constant | Description
:----------- | :----------
E | The value of Math.E from your JavaScript runtime
PI | The value of Math.PI from your JavaScript runtime
true | Logical true value
false | Logical false value
null | The null value (added in this fork; upstream issue #228)
Pre-defined constants are stored in parser.consts. You can make changes to this property to customise the
constants available to your expressions. For example:
const parser = new Parser();
parser.consts.R = 1.234;
console.log(parser.parse('A+B/R').toString()); // ((A + B) / 1.234)To disable the pre-defined constants, you can replace or delete parser.consts:
const parser = new Parser();
parser.consts = {};Tests
cdto the project directory- Install development dependencies:
npm install --legacy-peer-deps(the dev toolchain predates strict peer resolution) - Run the tests:
npm test
