From 257adb516260f81bb71333fa7102bdcf3a65ec74 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu Date: Thu, 26 Feb 2026 23:33:12 +0100 Subject: [PATCH 1/2] fix: replace eval() with safe math expression parser Replace eval() in evaluateInput() with a custom recursive descent parser that only handles arithmetic (+, -, *, /, parentheses, decimals). Enable no-eval lint rule to prevent future usage. Fixes #8032 --- .oxlintrc.json | 2 +- src/lib/litegraph/src/litegraph.ts | 6 +- .../litegraph/src/utils/mathParser.test.ts | 93 +++++++++++++++ src/lib/litegraph/src/utils/mathParser.ts | 111 ++++++++++++++++++ src/lib/litegraph/src/utils/widget.test.ts | 40 +++++++ src/lib/litegraph/src/utils/widget.ts | 14 +-- 6 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 src/lib/litegraph/src/utils/mathParser.test.ts create mode 100644 src/lib/litegraph/src/utils/mathParser.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 677dc069169..e91b1f10e39 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -35,7 +35,7 @@ } ], "no-control-regex": "off", - "no-eval": "off", + "no-eval": "error", "no-redeclare": "error", "no-restricted-imports": [ "error", diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts index 2f7007a1df8..ff2d7b13b48 100644 --- a/src/lib/litegraph/src/litegraph.ts +++ b/src/lib/litegraph/src/litegraph.ts @@ -144,7 +144,11 @@ export { isColorable } from './utils/type' export { createUuidv4 } from './utils/uuid' export type { UUID } from './utils/uuid' export { truncateText } from './utils/textUtils' -export { getWidgetStep, resolveNodeRootGraphId } from './utils/widget' +export { + evaluateInput, + getWidgetStep, + resolveNodeRootGraphId +} from './utils/widget' export { distributeSpace, type SpaceRequest } from './utils/spaceDistribution' export { BaseWidget } from './widgets/BaseWidget' diff --git a/src/lib/litegraph/src/utils/mathParser.test.ts b/src/lib/litegraph/src/utils/mathParser.test.ts new file mode 100644 index 00000000000..ff455cd8a2f --- /dev/null +++ b/src/lib/litegraph/src/utils/mathParser.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'vitest' + +import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser' + +describe('evaluateMathExpression', () => { + test.each([ + ['2+3', 5], + ['10-4', 6], + ['3*7', 21], + ['15/3', 5] + ])('basic arithmetic: %s = %d', (input, expected) => { + expect(evaluateMathExpression(input)).toBe(expected) + }) + + test.each([ + ['2+3*4', 14], + ['(2+3)*4', 20], + ['10-2*3', 4], + ['10/2+3', 8] + ])('operator precedence: %s = %d', (input, expected) => { + expect(evaluateMathExpression(input)).toBe(expected) + }) + + test.each([ + ['3.14*2', 6.28], + ['.5+.5', 1], + ['1.5+2.5', 4], + ['0.1+0.2', 0.1 + 0.2] + ])('decimals: %s', (input, expected) => { + expect(evaluateMathExpression(input)).toBe(expected) + }) + + test.each([ + [' 2 + 3 ', 5], + [' 10 - 4 ', 6], + [' ( 2 + 3 ) * 4 ', 20] + ])('whitespace handling: "%s" = %d', (input, expected) => { + expect(evaluateMathExpression(input)).toBe(expected) + }) + + test.each([ + ['((2+3))', 5], + ['(1+(2*(3+4)))', 15], + ['((1+2)*(3+4))', 21] + ])('nested parentheses: %s = %d', (input, expected) => { + expect(evaluateMathExpression(input)).toBe(expected) + }) + + test.each([ + ['-5', -5], + ['-(3+2)', -5], + ['--5', 5], + ['+5', 5], + ['-3*2', -6], + ['2*-3', -6] + ])('unary operators: %s = %d', (input, expected) => { + expect(evaluateMathExpression(input)).toBe(expected) + }) + + test.each([ + ['2 /2+3 * 4.75- -6', 21.25], + ['2 / (2 + 3) * 4.33 - -6', 7.732], + ['12* 123/-(-5 + 2)', 492], + ['((80 - (19)))', 61], + ['(1 - 2) + -(-(-(-4)))', 3], + ['1 - -(-(-(-4)))', -3], + ['12* 123/(-5 + 2)', -492], + ['12 * -123', -1476], + ['((2.33 / (2.9+3.5)*4) - -6)', 7.45625], + ['123.45*(678.90 / (-2.5+ 11.5)-(80 -19) *33.25) / 20 + 11', -12042.760875], + [ + '(123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) - (123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) + (13 - 2)/ -(-11) ', + 1 + ] + ])('complex expression: %s', (input, expected) => { + expect(evaluateMathExpression(input)).toBeCloseTo(expected as number) + }) + + test.each(['', 'abc', '2+', '(2+3', '2+3)', '()', '*3', '2 3'])( + 'invalid input returns undefined: "%s"', + (input) => { + expect(evaluateMathExpression(input)).toBeUndefined() + } + ) + + test('division by zero returns Infinity', () => { + expect(evaluateMathExpression('1/0')).toBe(Infinity) + }) + + test('0/0 returns NaN', () => { + expect(evaluateMathExpression('0/0')).toBeNaN() + }) +}) diff --git a/src/lib/litegraph/src/utils/mathParser.ts b/src/lib/litegraph/src/utils/mathParser.ts new file mode 100644 index 00000000000..e3834d4e193 --- /dev/null +++ b/src/lib/litegraph/src/utils/mathParser.ts @@ -0,0 +1,111 @@ +type Token = { type: 'number'; value: number } | { type: 'op'; value: string } + +function tokenize(input: string): Token[] | undefined { + const tokens: Token[] = [] + const re = /(\d+(?:\.\d*)?|\.\d+)|([+\-*/()])/g + let lastIndex = 0 + + for (const match of input.matchAll(re)) { + const gap = input.slice(lastIndex, match.index) + if (gap.trim()) return undefined + lastIndex = match.index + match[0].length + + if (match[1]) tokens.push({ type: 'number', value: parseFloat(match[1]) }) + else tokens.push({ type: 'op', value: match[2] }) + } + + if (input.slice(lastIndex).trim()) return undefined + return tokens +} + +/** + * Evaluates a basic arithmetic expression string containing + * `+`, `-`, `*`, `/`, parentheses, and decimal numbers. + * Returns `undefined` for empty or malformed input. + */ +export function evaluateMathExpression(input: string): number | undefined { + const tokenized = tokenize(input) + if (!tokenized || tokenized.length === 0) return undefined + + const tokens: Token[] = tokenized + let pos = 0 + + function peek(): Token | undefined { + return tokens[pos] + } + + function consume(): Token { + return tokens[pos++] + } + + function unit(): number | undefined { + const t = peek() + if (!t) return undefined + + if (t.type === 'number') { + consume() + return t.value + } + + if (t.type === 'op' && t.value === '(') { + consume() + const result = expr() + if (result === undefined) return undefined + const closing = peek() + if (!closing || closing.type !== 'op' || closing.value !== ')') { + return undefined + } + consume() + return result + } + + return undefined + } + + function unary(): number | undefined { + const t = peek() + if (t?.type === 'op' && (t.value === '+' || t.value === '-')) { + consume() + const operand = unary() + if (operand === undefined) return undefined + return t.value === '-' ? -operand : operand + } + return unit() + } + + function factor(): number | undefined { + let left = unary() + if (left === undefined) return undefined + + while ( + peek()?.type === 'op' && + (peek()!.value === '*' || peek()!.value === '/') + ) { + const op = consume().value + const right = unary() + if (right === undefined) return undefined + left = op === '*' ? left * right : left / right + } + return left + } + + function expr(): number | undefined { + let left = factor() + if (left === undefined) return undefined + + while ( + peek()?.type === 'op' && + (peek()!.value === '+' || peek()!.value === '-') + ) { + const op = consume().value + const right = factor() + if (right === undefined) return undefined + left = op === '+' ? left + right : left - right + } + return left + } + + const result = expr() + if (result === undefined || pos !== tokens.length) return undefined + return result +} diff --git a/src/lib/litegraph/src/utils/widget.test.ts b/src/lib/litegraph/src/utils/widget.test.ts index 5e908dd43f4..1f984661bf7 100644 --- a/src/lib/litegraph/src/utils/widget.test.ts +++ b/src/lib/litegraph/src/utils/widget.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { IWidgetOptions } from '@/lib/litegraph/src/litegraph' import { + evaluateInput, getWidgetStep, resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' @@ -70,3 +71,42 @@ describe('resolveNodeRootGraphId', () => { expect(resolveNodeRootGraphId(node, 'app-root-id')).toBe('app-root-id') }) }) + +describe('evaluateInput', () => { + test.each([ + ['42', 42], + ['3.14', 3.14], + ['-7', -7], + ['0', 0] + ])('plain number: "%s" = %d', (input, expected) => { + expect(evaluateInput(input)).toBe(expected) + }) + + test.each([ + ['2+3', 5], + ['(4+2)*3', 18], + ['3.14*2', 6.28], + ['10/2+3', 8] + ])('expression: "%s" = %d', (input, expected) => { + expect(evaluateInput(input)).toBe(expected) + }) + + test('empty string returns 0 (Number("") === 0)', () => { + expect(evaluateInput('')).toBe(0) + }) + + test.each(['abc', 'hello world'])( + 'invalid input returns undefined: "%s"', + (input) => { + expect(evaluateInput(input)).toBeUndefined() + } + ) + + test('division by zero returns Infinity', () => { + expect(evaluateInput('1/0')).toBe(Infinity) + }) + + test('0/0 returns undefined (NaN is filtered)', () => { + expect(evaluateInput('0/0')).toBeUndefined() + }) +}) diff --git a/src/lib/litegraph/src/utils/widget.ts b/src/lib/litegraph/src/utils/widget.ts index 825d74bf3eb..b0eb98bee4e 100644 --- a/src/lib/litegraph/src/utils/widget.ts +++ b/src/lib/litegraph/src/utils/widget.ts @@ -2,6 +2,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets' import type { UUID } from '@/lib/litegraph/src/utils/uuid' +import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser' + /** * The step value for numeric widgets. * Use {@link IWidgetOptions.step2} if available, otherwise fallback to @@ -12,14 +14,10 @@ export function getWidgetStep(options: IWidgetOptions): number { } export function evaluateInput(input: string): number | undefined { - // Check if v is a valid equation or a number - if (/^[\d\s.()*+/-]+$/.test(input)) { - // Solve the equation if possible - try { - input = eval(input) - } catch { - // Ignore eval errors - } + const result = evaluateMathExpression(input) + if (result !== undefined) { + if (isNaN(result)) return undefined + return result } const newValue = Number(input) if (isNaN(newValue)) return undefined From 1f0b74e79a512743f5693e0de8627045abc60b8b Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Fri, 27 Feb 2026 04:32:23 +0100 Subject: [PATCH 2/2] fix: address math parser PR review feedback - Rename unit() to primary() for clarity - Add modulo (%) operator support - Normalize negative zero to positive zero - Add depth limit (200) for nested parentheses - Use isFinite() instead of isNaN() to reject Infinity - Add tests for edge cases, unary-after-binary, modulo, depth limit, scientific/hex notation, and Infinity Fixes #9272 Fixes #9273 Fixes #9274 Fixes #9275 --- .../litegraph/src/utils/mathParser.test.ts | 34 +++++++++++++++++-- src/lib/litegraph/src/utils/mathParser.ts | 19 +++++++---- src/lib/litegraph/src/utils/widget.test.ts | 19 +++++++++-- src/lib/litegraph/src/utils/widget.ts | 4 +-- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/lib/litegraph/src/utils/mathParser.test.ts b/src/lib/litegraph/src/utils/mathParser.test.ts index ff455cd8a2f..377c71d85cd 100644 --- a/src/lib/litegraph/src/utils/mathParser.test.ts +++ b/src/lib/litegraph/src/utils/mathParser.test.ts @@ -25,7 +25,9 @@ describe('evaluateMathExpression', () => { ['3.14*2', 6.28], ['.5+.5', 1], ['1.5+2.5', 4], - ['0.1+0.2', 0.1 + 0.2] + ['0.1+0.2', 0.1 + 0.2], + ['123.', 123], + ['123.+3', 126] ])('decimals: %s', (input, expected) => { expect(evaluateMathExpression(input)).toBe(expected) }) @@ -52,7 +54,11 @@ describe('evaluateMathExpression', () => { ['--5', 5], ['+5', 5], ['-3*2', -6], - ['2*-3', -6] + ['2*-3', -6], + ['1+-2', -1], + ['2--3', 5], + ['-2*-3', 6], + ['-(2+3)*-(4+5)', 45] ])('unary operators: %s = %d', (input, expected) => { expect(evaluateMathExpression(input)).toBe(expected) }) @@ -76,7 +82,7 @@ describe('evaluateMathExpression', () => { expect(evaluateMathExpression(input)).toBeCloseTo(expected as number) }) - test.each(['', 'abc', '2+', '(2+3', '2+3)', '()', '*3', '2 3'])( + test.each(['', 'abc', '2+', '(2+3', '2+3)', '()', '*3', '2 3', '.', '123..'])( 'invalid input returns undefined: "%s"', (input) => { expect(evaluateMathExpression(input)).toBeUndefined() @@ -90,4 +96,26 @@ describe('evaluateMathExpression', () => { test('0/0 returns NaN', () => { expect(evaluateMathExpression('0/0')).toBeNaN() }) + + test.each([ + ['10%3', 1], + ['10%3+1', 2], + ['7%2', 1] + ])('modulo: %s = %d', (input, expected) => { + expect(evaluateMathExpression(input)).toBe(expected) + }) + + test('negative zero is normalized to positive zero', () => { + expect(Object.is(evaluateMathExpression('-0'), 0)).toBe(true) + }) + + test('deeply nested parentheses exceeding depth limit returns undefined', () => { + const input = '('.repeat(201) + '1' + ')'.repeat(201) + expect(evaluateMathExpression(input)).toBeUndefined() + }) + + test('parentheses within depth limit evaluate correctly', () => { + const input = '('.repeat(200) + '1' + ')'.repeat(200) + expect(evaluateMathExpression(input)).toBe(1) + }) }) diff --git a/src/lib/litegraph/src/utils/mathParser.ts b/src/lib/litegraph/src/utils/mathParser.ts index e3834d4e193..0e88702959f 100644 --- a/src/lib/litegraph/src/utils/mathParser.ts +++ b/src/lib/litegraph/src/utils/mathParser.ts @@ -2,7 +2,7 @@ type Token = { type: 'number'; value: number } | { type: 'op'; value: string } function tokenize(input: string): Token[] | undefined { const tokens: Token[] = [] - const re = /(\d+(?:\.\d*)?|\.\d+)|([+\-*/()])/g + const re = /(\d+(?:\.\d*)?|\.\d+)|([+\-*/%()])/g let lastIndex = 0 for (const match of input.matchAll(re)) { @@ -20,7 +20,7 @@ function tokenize(input: string): Token[] | undefined { /** * Evaluates a basic arithmetic expression string containing - * `+`, `-`, `*`, `/`, parentheses, and decimal numbers. + * `+`, `-`, `*`, `/`, `%`, parentheses, and decimal numbers. * Returns `undefined` for empty or malformed input. */ export function evaluateMathExpression(input: string): number | undefined { @@ -29,6 +29,8 @@ export function evaluateMathExpression(input: string): number | undefined { const tokens: Token[] = tokenized let pos = 0 + let depth = 0 + const MAX_DEPTH = 200 function peek(): Token | undefined { return tokens[pos] @@ -38,7 +40,7 @@ export function evaluateMathExpression(input: string): number | undefined { return tokens[pos++] } - function unit(): number | undefined { + function primary(): number | undefined { const t = peek() if (!t) return undefined @@ -48,6 +50,7 @@ export function evaluateMathExpression(input: string): number | undefined { } if (t.type === 'op' && t.value === '(') { + if (++depth > MAX_DEPTH) return undefined consume() const result = expr() if (result === undefined) return undefined @@ -56,6 +59,7 @@ export function evaluateMathExpression(input: string): number | undefined { return undefined } consume() + depth-- return result } @@ -70,7 +74,7 @@ export function evaluateMathExpression(input: string): number | undefined { if (operand === undefined) return undefined return t.value === '-' ? -operand : operand } - return unit() + return primary() } function factor(): number | undefined { @@ -79,12 +83,13 @@ export function evaluateMathExpression(input: string): number | undefined { while ( peek()?.type === 'op' && - (peek()!.value === '*' || peek()!.value === '/') + (peek()!.value === '*' || peek()!.value === '/' || peek()!.value === '%') ) { const op = consume().value const right = unary() if (right === undefined) return undefined - left = op === '*' ? left * right : left / right + left = + op === '*' ? left * right : op === '/' ? left / right : left % right } return left } @@ -107,5 +112,5 @@ export function evaluateMathExpression(input: string): number | undefined { const result = expr() if (result === undefined || pos !== tokens.length) return undefined - return result + return result === 0 ? 0 : result } diff --git a/src/lib/litegraph/src/utils/widget.test.ts b/src/lib/litegraph/src/utils/widget.test.ts index 1f984661bf7..6eb9707dd2a 100644 --- a/src/lib/litegraph/src/utils/widget.test.ts +++ b/src/lib/litegraph/src/utils/widget.test.ts @@ -102,11 +102,26 @@ describe('evaluateInput', () => { } ) - test('division by zero returns Infinity', () => { - expect(evaluateInput('1/0')).toBe(Infinity) + test('division by zero returns undefined', () => { + expect(evaluateInput('1/0')).toBeUndefined() }) test('0/0 returns undefined (NaN is filtered)', () => { expect(evaluateInput('0/0')).toBeUndefined() }) + + test('scientific notation via Number() fallback', () => { + expect(evaluateInput('1e5')).toBe(100000) + }) + + test('hex notation via Number() fallback', () => { + expect(evaluateInput('0xff')).toBe(255) + }) + + test.each(['Infinity', '-Infinity'])( + '"%s" returns undefined (non-finite rejected)', + (input) => { + expect(evaluateInput(input)).toBeUndefined() + } + ) }) diff --git a/src/lib/litegraph/src/utils/widget.ts b/src/lib/litegraph/src/utils/widget.ts index b0eb98bee4e..7f0051c79f4 100644 --- a/src/lib/litegraph/src/utils/widget.ts +++ b/src/lib/litegraph/src/utils/widget.ts @@ -16,11 +16,11 @@ export function getWidgetStep(options: IWidgetOptions): number { export function evaluateInput(input: string): number | undefined { const result = evaluateMathExpression(input) if (result !== undefined) { - if (isNaN(result)) return undefined + if (!isFinite(result)) return undefined return result } const newValue = Number(input) - if (isNaN(newValue)) return undefined + if (!isFinite(newValue)) return undefined return newValue }