Files
OpenCut/apps/web/src/utils/math.ts
T
Rayan SalhabGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Mazecoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
eb66e03f0e fix(security): replace new Function() with safe math expression parser (#753)
* fix(security): replace new Function() with safe math expression parser

Replace the dangerous new Function() constructor in evaluateMathExpression()
with a recursive descent parser that safely evaluates basic arithmetic
expressions.

The Function constructor is a security anti-pattern that could enable
arbitrary code execution if the validation regex is ever bypassed or
relaxed. The new parser:

- Only supports numbers, +, -, *, /, parentheses, and whitespace
- Has no eval-like functionality that could execute arbitrary code
- Maintains backward compatibility with existing expressions
- Handles operator precedence and parentheses correctly

Fixes #725

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Maze <167211895+mazeincoding@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-29 18:27:05 +02:00

277 lines
5.6 KiB
TypeScript

export function clamp({
value,
min,
max,
}: {
value: number;
min: number;
max: number;
}): number {
return Math.max(min, Math.min(max, value));
}
export function clampRound({
value,
min,
max,
}: {
value: number;
min: number;
max: number;
}): number {
return Math.round(clamp({ value, min, max }));
}
export function getFractionDigitsForStep({ step }: { step: number }): number {
const normalizedStep = step.toString().toLowerCase();
if (normalizedStep.includes("e-")) {
return Number(normalizedStep.split("e-")[1] ?? 0);
}
const [, fractionalPart = ""] = normalizedStep.split(".");
return fractionalPart.length;
}
export function snapToStep({
value,
step,
}: {
value: number;
step: number;
}): number {
if (step <= 0) return value;
const snappedValue = Math.round(value / step) * step;
return Number(
snappedValue.toFixed(getFractionDigitsForStep({ step })),
);
}
export function isNearlyEqual({
leftValue,
rightValue,
epsilon = 0.0001,
}: {
leftValue: number;
rightValue: number;
epsilon?: number;
}): boolean {
return Math.abs(leftValue - rightValue) <= epsilon;
}
export function formatNumberForDisplay({
value,
fractionDigits,
minFractionDigits = 0,
maxFractionDigits = 6,
}: {
value: number;
fractionDigits?: number;
minFractionDigits?: number;
maxFractionDigits?: number;
}): string {
const resolvedMaxFractionDigits = Math.max(
0,
fractionDigits ?? maxFractionDigits,
);
const resolvedMinFractionDigits = Math.min(
Math.max(0, fractionDigits ?? minFractionDigits),
resolvedMaxFractionDigits,
);
const fixedValue = value.toFixed(resolvedMaxFractionDigits);
if (resolvedMaxFractionDigits === 0) {
return Number(fixedValue) === 0 ? "0" : fixedValue;
}
const [integerPart, fractionPart = ""] = fixedValue.split(".");
const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart;
let trimmedFractionPart = fractionPart;
while (
trimmedFractionPart.length > resolvedMinFractionDigits &&
trimmedFractionPart.endsWith("0")
) {
trimmedFractionPart = trimmedFractionPart.slice(0, -1);
}
return trimmedFractionPart
? `${normalizedIntegerPart}.${trimmedFractionPart}`
: normalizedIntegerPart;
}
/**
* Safely evaluates a mathematical expression without using eval() or new Function().
* Supports +, -, *, /, parentheses, and decimal numbers.
*/
export function evaluateMathExpression({
input,
}: {
input: string;
}): number | null {
const sanitized = input.trim();
if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
try {
return parseExpression(sanitized);
} catch {
return null;
}
}
/**
* Recursive descent parser for basic arithmetic expressions.
* Grammar:
* expression = term { ('+' | '-') term }
* term = factor { ('*' | '/') factor }
* factor = number | '(' expression ')'
*/
function parseExpression(input: string): number | null {
const tokens = tokenize(input);
if (tokens.length === 0) return null;
let index = 0;
function peek(): Token | null {
return tokens[index] ?? null;
}
function consume(): Token | null {
return tokens[index++] ?? null;
}
function parseExpressionLevel(): number {
let left = parseTerm();
while (true) {
const token = peek();
if (token?.type === "PLUS") {
consume();
left = left + parseTerm();
} else if (token?.type === "MINUS") {
consume();
left = left - parseTerm();
} else {
break;
}
}
return left;
}
function parseTerm(): number {
let left = parseFactor();
while (true) {
const token = peek();
if (token?.type === "MULTIPLY") {
consume();
left = left * parseFactor();
} else if (token?.type === "DIVIDE") {
consume();
const right = parseFactor();
if (right === 0) throw new Error("Division by zero");
left = left / right;
} else {
break;
}
}
return left;
}
function parseFactor(): number {
const token = peek();
if (!token) throw new Error("Unexpected end of expression");
if (token.type === "NUMBER") {
consume();
return token.value;
}
if (token.type === "MINUS") {
consume();
return -parseFactor();
}
if (token.type === "LPAREN") {
consume();
const value = parseExpressionLevel();
const next = peek();
if (next?.type !== "RPAREN") {
throw new Error("Missing closing parenthesis");
}
consume();
return value;
}
throw new Error(`Unexpected token: ${token.type}`);
}
const result = parseExpressionLevel();
if (index !== tokens.length) {
return null;
}
if (!Number.isFinite(result)) return null;
return result;
}
type Token =
| { type: "NUMBER"; value: number }
| { type: "PLUS" }
| { type: "MINUS" }
| { type: "MULTIPLY" }
| { type: "DIVIDE" }
| { type: "LPAREN" }
| { type: "RPAREN" };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const str = input.trim();
while (i < str.length) {
const char = str[i];
if (/\s/.test(char)) {
i++;
continue;
}
if (/\d/.test(char)) {
let numStr = "";
while (i < str.length && (/\d/.test(str[i]) || str[i] === ".")) {
numStr += str[i];
i++;
}
const value = Number(numStr);
if (!Number.isFinite(value)) {
throw new Error(`Invalid number: ${numStr}`);
}
tokens.push({ type: "NUMBER", value });
continue;
}
switch (char) {
case "+":
tokens.push({ type: "PLUS" });
break;
case "-":
tokens.push({ type: "MINUS" });
break;
case "*":
tokens.push({ type: "MULTIPLY" });
break;
case "/":
tokens.push({ type: "DIVIDE" });
break;
case "(":
tokens.push({ type: "LPAREN" });
break;
case ")":
tokens.push({ type: "RPAREN" });
break;
default:
throw new Error(`Invalid character: ${char}`);
}
i++;
}
return tokens;
}