Skip to content

Commit 7eed95d

Browse files
Security fix for CVE-2025-12735, CVE-2025-13204, and member function bypass (#6)
* Initial plan * Add security protections for CVE-2025-12735, CVE-2025-13204, and Issue silentmatt#289 - Block arbitrary function calls passed via evaluation context - Block prototype pollution via __proto__, prototype, constructor access - Block member function call bypass vulnerability - Allow inline-defined functions (IFUNDEF) as they are safe - Allow registered functions in parser.functions - Allow safe Math functions (sin, cos, sqrt, etc.) - Update tests to use secure function registration pattern - Add comprehensive security test suite Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> * Fix code review issues: add validateAllowedFunction to IFUNCALL, use counter-based keys for inline functions - Add validateAllowedFunction check in IFUNCALL handler to prevent bypassing security - Use monotonically increasing counter for inline function keys instead of user-controlled names - Prevent collision attacks on inline function registration Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> * Update tests to use secure function registration pattern and add BREAKING_CHANGES.md - Update remaining 2 tests in operators-logical.ts to register functions in parser.functions - Add comprehensive BREAKING_CHANGES.md documenting security changes and migration guide Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> * Fix TypeScript type error in security test Change empty function to return number to satisfy Value type constraint Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> * Bump version to 5.0.0 for breaking changes per semantic versioning - Update package.json version from 4.2.0 to 5.0.0 - Update BREAKING_CHANGES.md to reference version 5.0.0 - Update package-lock.json accordingly Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Sander-Toonen <5106372+Sander-Toonen@users.noreply.github.com>
1 parent 8c566fe commit 7eed95d

7 files changed

Lines changed: 505 additions & 11 deletions

File tree

BREAKING_CHANGES.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Breaking Changes
2+
3+
This document lists breaking changes in the library to help users migrate between versions.
4+
5+
## Version 5.0.0
6+
7+
### Security: Functions Must Be Registered Explicitly
8+
9+
**Background**: This change addresses critical security vulnerabilities:
10+
- [CVE-2025-12735](https://github.com/advisories/GHSA-jc85-fpwf-qm7x) - Code injection via arbitrary function calls
11+
- [CVE-2025-13204](https://github.com/advisories) - Prototype pollution via `__proto__`, `prototype`, `constructor` access
12+
- [silentmatt/expr-eval#289](https://github.com/silentmatt/expr-eval/issues/289) - Member function call bypass
13+
14+
**What Changed**: Functions can no longer be passed directly via the evaluation context. All functions that need to be called from expressions must be explicitly registered in `parser.functions`.
15+
16+
**Before (Vulnerable)**:
17+
```typescript
18+
const parser = new Parser();
19+
20+
// This pattern is NO LONGER ALLOWED
21+
parser.evaluate('customFunc()', { customFunc: () => 'result' });
22+
23+
// This also NO LONGER WORKS
24+
parser.evaluate('obj.method()', {
25+
obj: {
26+
method: () => 'dangerous'
27+
}
28+
});
29+
```
30+
31+
**After (Secure)**:
32+
```typescript
33+
const parser = new Parser();
34+
35+
// Register functions explicitly
36+
parser.functions.customFunc = () => 'result';
37+
parser.evaluate('customFunc()');
38+
39+
// For methods on objects, register them as top-level functions
40+
parser.functions.objMethod = () => 'safe';
41+
parser.evaluate('objMethod()');
42+
```
43+
44+
**What Still Works**:
45+
- Passing primitive values (strings, numbers, booleans) via context
46+
- Passing arrays and objects with non-function properties via context
47+
- Using built-in Math functions (sin, cos, sqrt, etc.)
48+
- Using inline-defined functions in expressions: `(f(x) = x * 2)(5)`
49+
- Using functions registered in `parser.functions`
50+
51+
**Migration Guide**:
52+
53+
1. **Identify function usage**: Search your codebase for patterns like `evaluate('...', { fn: ... })` where `fn` is a function.
54+
55+
2. **Register functions before evaluation**:
56+
```typescript
57+
// Before
58+
parser.evaluate('calculate(x)', { calculate: myFunc, x: 5 });
59+
60+
// After
61+
parser.functions.calculate = myFunc;
62+
parser.evaluate('calculate(x)', { x: 5 });
63+
```
64+
65+
3. **For dynamic functions**: If you need to register functions dynamically:
66+
```typescript
67+
const parser = new Parser();
68+
parser.functions.dynamicFn = createDynamicFunction();
69+
const result = parser.evaluate('dynamicFn()');
70+
delete parser.functions.dynamicFn; // Clean up if needed
71+
```
72+
73+
### Protected Properties
74+
75+
Access to the following properties is now blocked to prevent prototype pollution attacks:
76+
- `__proto__`
77+
- `prototype`
78+
- `constructor`
79+
80+
Attempting to access these properties in variable names or member expressions will throw an `AccessError`.
81+
82+
**Example**:
83+
```typescript
84+
// These will throw AccessError
85+
parser.evaluate('x.__proto__', { x: {} });
86+
parser.evaluate('__proto__', { __proto__: {} });
87+
```

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pro-fa/expr-eval",
3-
"version": "4.2.0",
3+
"version": "5.0.0",
44
"description": "Mathematical expression evaluator",
55
"keywords": [
66
"expression",

src/core/evaluate.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import { ExpressionValidator } from '../validation/expression-validator.js';
1717
// cSpell:words IOBJECT IOBJECTEND
1818
// cSpell:words nstack
1919

20+
/**
21+
* Counter for generating unique keys for inline-defined functions.
22+
* This prevents collision attacks by using a monotonically increasing counter.
23+
*/
24+
let inlineFunctionCounter = 0;
25+
2026
/**
2127
* Wrapper for lazy expression evaluation
2228
* Used for short-circuit evaluation of logical operators and conditionals
@@ -170,6 +176,8 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
170176
let valueResolved = false;
171177
if (variableName in values) {
172178
const variableValue = values[variableName];
179+
// Security: Validate that functions from context are allowed before pushing onto stack
180+
ExpressionValidator.validateAllowedFunction(variableValue, expr.functions, expr.toString());
173181
nstack.push(variableValue);
174182
valueResolved = true;
175183
} else {
@@ -184,13 +192,19 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
184192
// The parser's resolver function returned { alias: "xxx" }, we want to use
185193
// resolved.alias in place of token.value.
186194
if (resolvedVariable.alias in values) {
187-
nstack.push(values[resolvedVariable.alias]);
195+
const aliasValue = values[resolvedVariable.alias];
196+
// Security: Validate that functions from context are allowed
197+
ExpressionValidator.validateAllowedFunction(aliasValue, expr.functions, expr.toString());
198+
nstack.push(aliasValue);
188199
valueResolved = true;
189200
}
190201
} else if (typeof resolvedVariable === 'object' && resolvedVariable && 'value' in resolvedVariable) {
191202
// The parser's resolver function returned { value: <something> }, use <something>
192203
// as the value of the token.
193-
nstack.push(resolvedVariable.value);
204+
const resolvedValue = resolvedVariable.value;
205+
// Security: Validate that functions from context are allowed
206+
ExpressionValidator.validateAllowedFunction(resolvedValue, expr.functions, expr.toString());
207+
nstack.push(resolvedValue);
194208
valueResolved = true;
195209
}
196210
}
@@ -215,6 +229,8 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
215229
}
216230
const functionToCall = nstack.pop();
217231
ExpressionValidator.validateFunctionCall(functionToCall, String(functionToCall), expr.toString());
232+
// Security: Validate the function is allowed before calling it
233+
ExpressionValidator.validateAllowedFunction(functionToCall, expr.functions, expr.toString());
218234
nstack.push(functionToCall.apply(undefined, functionArgs));
219235
} else if (type === IFUNDEF) {
220236
// Create closure to keep references to arguments and expression
@@ -238,6 +254,10 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
238254
value: functionName,
239255
writable: false
240256
});
257+
// Security: Register the inline-defined function as allowed using a unique counter-based key
258+
// This prevents collision attacks since the key cannot be predicted or controlled by user input
259+
const uniqueKey = `__inline_fn_${inlineFunctionCounter++}__`;
260+
expr.functions[uniqueKey] = userDefinedFunction;
241261
values[functionName] = userDefinedFunction;
242262
return userDefinedFunction;
243263
})());
@@ -247,7 +267,13 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
247267
nstack.push(token);
248268
} else if (type === IMEMBER) {
249269
const memberParent = nstack.pop();
250-
nstack.push(memberParent === undefined || token === undefined || token.value === undefined ? undefined : memberParent[token.value]);
270+
const propertyName = token.value as string;
271+
// Security: Block access to dangerous prototype properties
272+
ExpressionValidator.validateMemberAccess(propertyName, expr.toString());
273+
const memberValue = memberParent === undefined || token === undefined || token.value === undefined ? undefined : memberParent[propertyName];
274+
// Security: Validate that member functions are allowed before pushing onto stack
275+
ExpressionValidator.validateAllowedFunction(memberValue, expr.functions, expr.toString());
276+
nstack.push(memberValue);
251277
} else if (type === IENDSTATEMENT) {
252278
nstack.pop();
253279
} else if (type === IARRAY) {

src/validation/expression-validator.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,54 @@
77
*/
88

99
import { AccessError, FunctionError } from '../types/errors.js';
10+
import type { OperatorFunction } from '../types/index.js';
11+
12+
/**
13+
* Set of dangerous property names that could lead to prototype pollution
14+
*/
15+
const DANGEROUS_PROPERTIES = new Set(['__proto__', 'prototype', 'constructor']);
16+
17+
/**
18+
* Safe Math functions that are allowed by default.
19+
* These are immutable references to the standard Math object methods.
20+
*/
21+
const SAFE_MATH_FUNCTIONS: ReadonlySet<Function> = new Set([
22+
Math.abs,
23+
Math.acos,
24+
Math.asin,
25+
Math.atan,
26+
Math.atan2,
27+
Math.ceil,
28+
Math.cos,
29+
Math.exp,
30+
Math.floor,
31+
Math.log,
32+
Math.max,
33+
Math.min,
34+
Math.pow,
35+
Math.random,
36+
Math.round,
37+
Math.sin,
38+
Math.sqrt,
39+
Math.tan,
40+
Math.log10,
41+
Math.log2,
42+
Math.log1p,
43+
Math.expm1,
44+
Math.cosh,
45+
Math.sinh,
46+
Math.tanh,
47+
Math.acosh,
48+
Math.asinh,
49+
Math.atanh,
50+
Math.hypot,
51+
Math.trunc,
52+
Math.sign,
53+
Math.cbrt,
54+
Math.clz32,
55+
Math.imul,
56+
Math.fround
57+
]);
1058

1159
/**
1260
* Validation utilities for expression evaluation
@@ -16,7 +64,7 @@ export class ExpressionValidator {
1664
* Validates variable name to prevent prototype pollution
1765
*/
1866
static validateVariableName(variableName: string, expressionString: string): void {
19-
if (/^__proto__|prototype|constructor$/.test(variableName)) {
67+
if (DANGEROUS_PROPERTIES.has(variableName)) {
2068
throw new AccessError(
2169
'Prototype access detected',
2270
{
@@ -27,6 +75,78 @@ export class ExpressionValidator {
2775
}
2876
}
2977

78+
/**
79+
* Validates member access to prevent prototype pollution attacks.
80+
* Blocks access to __proto__, prototype, and constructor properties.
81+
*
82+
* @param propertyName - The property name being accessed
83+
* @param expressionString - The full expression string for error context
84+
* @throws {AccessError} When trying to access dangerous prototype properties
85+
*/
86+
static validateMemberAccess(propertyName: string, expressionString: string): void {
87+
if (DANGEROUS_PROPERTIES.has(propertyName)) {
88+
throw new AccessError(
89+
`Prototype access detected in member expression`,
90+
{
91+
propertyName,
92+
expression: expressionString
93+
}
94+
);
95+
}
96+
}
97+
98+
/**
99+
* Checks if a function is allowed to be called.
100+
* Only functions explicitly registered in expr.functions or safe Math functions are allowed.
101+
*
102+
* @param fn - The function to check
103+
* @param registeredFunctions - The registered functions from the expression's parser
104+
* @returns true if the function is allowed, false otherwise
105+
*/
106+
static isAllowedFunction(fn: unknown, registeredFunctions: Record<string, OperatorFunction>): boolean {
107+
if (typeof fn !== 'function') {
108+
return true; // Non-functions are not subject to function call restrictions
109+
}
110+
111+
// Check if it's a safe Math function
112+
if (SAFE_MATH_FUNCTIONS.has(fn as Function)) {
113+
return true;
114+
}
115+
116+
// Check if it's registered in expr.functions
117+
for (const key in registeredFunctions) {
118+
if (Object.prototype.hasOwnProperty.call(registeredFunctions, key) && registeredFunctions[key] === fn) {
119+
return true;
120+
}
121+
}
122+
123+
return false;
124+
}
125+
126+
/**
127+
* Validates that a function is allowed to be called.
128+
* Throws an error if the function is not in the allowed list.
129+
*
130+
* @param fn - The function to validate
131+
* @param registeredFunctions - The registered functions from the expression's parser
132+
* @param expressionString - The full expression string for error context
133+
* @throws {FunctionError} When trying to call an unregistered function
134+
*/
135+
static validateAllowedFunction(
136+
fn: unknown,
137+
registeredFunctions: Record<string, OperatorFunction>,
138+
expressionString: string
139+
): void {
140+
if (typeof fn === 'function' && !this.isAllowedFunction(fn, registeredFunctions)) {
141+
throw new FunctionError(
142+
'Calling unregistered functions is not allowed for security reasons',
143+
{
144+
expression: expressionString
145+
}
146+
);
147+
}
148+
}
149+
30150
/**
31151
* Validates function call parameters
32152
*/

test/operators/operators-logical.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,21 @@ describe('Logical Operators TypeScript Test', () => {
4242

4343
it('skips rhs when lhs is false', () => {
4444
const notCalled = spy(returnFalse);
45+
// Security: Functions must be registered in parser.functions before use
46+
const parser = new Parser();
47+
parser.functions.notCalled = notCalled;
4548

46-
expect(Parser.evaluate('false and notCalled()', { notCalled: notCalled })).toBe(false);
49+
expect(parser.evaluate('false and notCalled()')).toBe(false);
4750
expect(notCalled.called).toBe(false);
4851
});
4952

5053
it('evaluates rhs when lhs is true', () => {
5154
const called = spy(returnFalse);
55+
// Security: Functions must be registered in parser.functions before use
56+
const parser = new Parser();
57+
parser.functions.called = called;
5258

53-
expect(Parser.evaluate('true and called()', { called: called })).toBe(false);
59+
expect(parser.evaluate('true and called()')).toBe(false);
5460
expect(called.called).toBe(true);
5561
});
5662
});
@@ -82,15 +88,21 @@ describe('Logical Operators TypeScript Test', () => {
8288

8389
it('skips rhs when lhs is true', () => {
8490
const notCalled = spy(returnFalse);
91+
// Security: Functions must be registered in parser.functions before use
92+
const parser = new Parser();
93+
parser.functions.notCalled = notCalled;
8594

86-
expect(Parser.evaluate('true or notCalled()', { notCalled: notCalled })).toBe(true);
95+
expect(parser.evaluate('true or notCalled()')).toBe(true);
8796
expect(notCalled.called).toBe(false);
8897
});
8998

9099
it('evaluates rhs when lhs is false', () => {
91100
const called = spy(returnTrue);
101+
// Security: Functions must be registered in parser.functions before use
102+
const parser = new Parser();
103+
parser.functions.called = called;
92104

93-
expect(Parser.evaluate('false or called()', { called: called })).toBe(true);
105+
expect(parser.evaluate('false or called()')).toBe(true);
94106
expect(called.called).toBe(true);
95107
});
96108
});

0 commit comments

Comments
 (0)