From 6dd5ed6617d1ba854f063d09d182b7dece00bca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Fri, 5 Sep 2025 21:36:57 +0200 Subject: [PATCH 1/2] Implement budget --- README.md | 22 +++++++++++++++++++++- lib/ExpressionSync.js | 17 ++++++++++++++++- test/async/budget.js | 22 ++++++++++++++++++++++ test/sync/budget.js | 20 ++++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 test/async/budget.js create mode 100644 test/sync/budget.js diff --git a/README.md b/README.md index 27b6f28..e899ad8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Please consider following this project's author, [Jon Schlinkert](https://github * [.variables](#variables) - [Options](#options) * [booleanLogicalOperators](#booleanlogicaloperators) + * [budget](#budget) * [functions](#functions) * [Top-Level await](#top-level-await) * [generate](#generate) @@ -164,6 +165,25 @@ console.log(await evaluate(parse('a && b'), { a: undefined, b: false }, options) console.log(await evaluate(parse('a || b'), { a: false, b: undefined }, options)); //=> false ``` +### budget + +Type: `number` +Default: `undefined` + +Limit the complexity of an expression by capping the number of AST node visits during evaluation. When the budget is exceeded, evaluation throws a `RangeError` with the message `Expression complexity budget exceeded`. + +```js +// Allows up to 1 node visit (the NumericLiteral) +console.log(evaluate.sync(parse('1'), {}, { budget: 1 })); //=> 1 + +// Exceeds budget: BinaryExpression + two NumericLiterals = 3 visits +try { + evaluate.sync(parse('1 + 2'), {}, { budget: 2 }); +} catch (err) { + console.log(err.message); //=> 'Expression complexity budget exceeded' +} +``` + ### functions Type: `boolean` @@ -389,4 +409,4 @@ Released under the [MIT License](LICENSE). *** -_This file was generated by [verb-generate-readme](https://github.com/verbose/verb-generate-readme), v0.8.0, on April 19, 2025._ \ No newline at end of file +_This file was generated by [verb-generate-readme](https://github.com/verbose/verb-generate-readme), v0.8.0, on April 19, 2025._ diff --git a/lib/ExpressionSync.js b/lib/ExpressionSync.js index 7a5f26e..dbc1a28 100644 --- a/lib/ExpressionSync.js +++ b/lib/ExpressionSync.js @@ -23,6 +23,11 @@ class ExpressionSync { this.options = { maxExpressionDepth: MAX_EXPRESSION_DEPTH, maxArrayLength: MAX_ARRAY_LENGTH, + // Maximum allowed number of node visits during evaluation. + // When provided, the evaluator will decrement this "budget" on each + // node visit and throw if it reaches zero. This guards against + // overly complex expressions consuming too much CPU. + budget: undefined, ...options }; @@ -32,7 +37,9 @@ class ExpressionSync { stackDepth: 0, expressionDepth: 0, seenObjects: new WeakSet(), - templateLiterals: new Set() + templateLiterals: new Set(), + // Remaining budget of visits. Undefined means unlimited. + budget: typeof this.options.budget === 'number' ? this.options.budget : undefined }; this.stack = []; @@ -46,6 +53,14 @@ class ExpressionSync { } incrementDepth() { + // Enforce complexity budget if specified + if (typeof this.state.budget === 'number') { + if (this.state.budget <= 0) { + throw new RangeError('Expression complexity budget exceeded'); + } + this.state.budget--; + } + this.state.expressionDepth++; if (this.state.expressionDepth > MAX_EXPRESSION_DEPTH) { diff --git a/test/async/budget.js b/test/async/budget.js new file mode 100644 index 0000000..f2b4854 --- /dev/null +++ b/test/async/budget.js @@ -0,0 +1,22 @@ +'use strict'; + +const { strict: assert } = require('assert'); +const { evaluate } = require('../support'); + +const e = (input, context, options) => evaluate(input, context, { ...options }); + +describe('budget (async)', () => { + it('should allow simple expression within budget', async () => { + assert.equal(await e('1', {}, { budget: 1 }), 1); + }); + + it('should reject when budget is exceeded on binary expression', async () => { + await assert.rejects(() => e('1 + 2', {}, { budget: 2 }), /Expression complexity budget exceeded/); + }); + + it('should reject when budget is exceeded on array expression', async () => { + // Visits: ArrayExpression + 3 NumericLiterals = 4 visits + await assert.rejects(() => e('[1,2,3]', {}, { budget: 3 }), /Expression complexity budget exceeded/); + }); +}); + diff --git a/test/sync/budget.js b/test/sync/budget.js new file mode 100644 index 0000000..777528f --- /dev/null +++ b/test/sync/budget.js @@ -0,0 +1,20 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { evaluate: e } = require('../support'); + +describe('budget (sync)', () => { + it('should allow simple expression within budget', () => { + assert.equal(e.sync('1', {}, { budget: 1 }), 1); + }); + + it('should throw when budget is exceeded on binary expression', () => { + assert.throws(() => e.sync('1 + 2', {}, { budget: 2 }), /Expression complexity budget exceeded/); + }); + + it('should throw when budget is exceeded on array expression', () => { + // Visits: ArrayExpression + 3 NumericLiterals = 4 visits + assert.throws(() => e.sync('[1,2,3]', {}, { budget: 3 }), /Expression complexity budget exceeded/); + }); +}); + From 933b2902409dcfa89c81f8a60d1a26c89e27c296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Fri, 5 Sep 2025 21:43:51 +0200 Subject: [PATCH 2/2] Export typescript --- index.d.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 70 insertions(+) create mode 100644 index.d.ts diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..dc59393 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,68 @@ +// TypeScript declarations for eval-estree-expression + +export interface VariablesOptions { + withMembers?: boolean; +} + +export interface VisitorsMap { + [nodeType: string]: (node: any, context: any, parent?: any) => any; +} + +export interface EvaluateOptions { + // Behavior + booleanLogicalOperators?: boolean; + strict?: boolean; + regexOperator?: boolean; // defaults true in runtime + allowContextStringLiterals?: boolean; + + // Complexity limits + budget?: number; + maxArrayLength?: number; + maxExpressionDepth?: number; + + // Function support + functions?: boolean; + generate?: (node: any) => string; // escodegen.generate + + // Custom visitors + visitors?: VisitorsMap; +} + +export type Context = Record | undefined; + +export interface EvaluateFn { + (tree: any, context?: Context, options?: EvaluateOptions): Promise; + sync(tree: any, context?: Context, options?: EvaluateOptions): any; +} + +export declare class ExpressionSync { + constructor(tree: any, options?: EvaluateOptions, internalState?: { created?: boolean }); + + // Static + static readonly FAIL: unique symbol; + static readonly ExpressionSync: typeof ExpressionSync; + static variables(tree: any, options?: VariablesOptions): string[]; + static evaluate(tree: any, context?: Context, options?: EvaluateOptions): any; + + // Instance + evaluate(context?: Context): any; +} + +export declare class Expression extends ExpressionSync { + // Static + static readonly isAsync: true; + static readonly Expression: typeof Expression; + static readonly ExpressionSync: typeof ExpressionSync; + static variables(tree: any, options?: VariablesOptions): string[]; + static evaluate: EvaluateFn; + + // Instance + evaluate(context?: Context): Promise; +} + +export const variables: (tree: any, options?: VariablesOptions) => string[]; +export const evaluate: EvaluateFn; + +export { Expression, ExpressionSync }; +export default Expression; + diff --git a/package.json b/package.json index d069c26..d551d84 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "url": "https://github.com/jonschlinkert/eval-estree-expression/issues" }, "main": "index.js", + "types": "index.d.ts", "files": [ "index.js", + "index.d.ts", "lib" ], "scripts": {