From e3ea6c50226ba2e1f3319c2991c2424f49721c59 Mon Sep 17 00:00:00 2001 From: Sebastion Date: Tue, 26 May 2026 13:07:53 +0100 Subject: [PATCH] fix: replace new Function() with safe math expression parser (CWE-94) --- docs/app/api/chat/route.ts | 187 ++++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 5 deletions(-) diff --git a/docs/app/api/chat/route.ts b/docs/app/api/chat/route.ts index ff42bac34..01a1a9d4e 100644 --- a/docs/app/api/chat/route.ts +++ b/docs/app/api/chat/route.ts @@ -105,15 +105,192 @@ function getStockPrice({ symbol }: { symbol: string }): Promise { }); } +// ── Safe math expression parser (replaces new Function) ── + +type MathToken = + | { type: "number"; value: number } + | { type: "op"; value: string } + | { type: "func"; value: string } + | { type: "paren"; value: string } + | { type: "comma" }; + +const ALLOWED_FUNCTIONS = new Set([ + "Math.sqrt", + "Math.pow", + "Math.abs", + "Math.ceil", + "Math.floor", + "Math.round", + "sqrt", + "pow", + "abs", + "ceil", + "floor", + "round", +]); + +const MATH_FNS: Record number> = { + sqrt: Math.sqrt, + pow: Math.pow, + abs: Math.abs, + ceil: Math.ceil, + floor: Math.floor, + round: Math.round, +}; + +function tokenizeMath(expr: string): MathToken[] { + const tokens: MathToken[] = []; + let i = 0; + while (i < expr.length) { + const ch = expr[i]; + if (/\s/.test(ch)) { + i++; + continue; + } + if (/[0-9]/.test(ch) || (ch === "." && i + 1 < expr.length && /[0-9]/.test(expr[i + 1]))) { + let num = ""; + while (i < expr.length && (/[0-9]/.test(expr[i]) || expr[i] === ".")) num += expr[i++]; + tokens.push({ type: "number", value: parseFloat(num) }); + continue; + } + if ("+-*/%".includes(ch)) { + tokens.push({ type: "op", value: ch }); + i++; + continue; + } + if (ch === "(" || ch === ")") { + tokens.push({ type: "paren", value: ch }); + i++; + continue; + } + if (ch === ",") { + tokens.push({ type: "comma" }); + i++; + continue; + } + if (/[a-zA-Z]/.test(ch)) { + let word = ""; + while (i < expr.length && /[a-zA-Z.]/.test(expr[i])) word += expr[i++]; + if (!ALLOWED_FUNCTIONS.has(word)) throw new Error("Unknown identifier: " + word); + const funcName = word.includes(".") ? word.split(".")[1] : word; + tokens.push({ type: "func", value: funcName }); + continue; + } + throw new Error("Invalid character: " + ch); + } + return tokens; +} + +type ParseCtx = { tokens: MathToken[]; pos: number }; + +function parseExpression(ctx: ParseCtx): number { + let left = parseTerm(ctx); + while (ctx.pos < ctx.tokens.length) { + const t = ctx.tokens[ctx.pos]; + if (t.type === "op" && (t.value === "+" || t.value === "-")) { + ctx.pos++; + const right = parseTerm(ctx); + left = t.value === "+" ? left + right : left - right; + } else break; + } + return left; +} + +function parseTerm(ctx: ParseCtx): number { + let left = parseUnary(ctx); + while (ctx.pos < ctx.tokens.length) { + const t = ctx.tokens[ctx.pos]; + if (t.type === "op" && (t.value === "*" || t.value === "/" || t.value === "%")) { + ctx.pos++; + const right = parseUnary(ctx); + if (t.value === "*") left *= right; + else if (t.value === "/") left /= right; + else left %= right; + } else break; + } + return left; +} + +function parseUnary(ctx: ParseCtx): number { + const t = ctx.tokens[ctx.pos]; + if (t && t.type === "op" && (t.value === "+" || t.value === "-")) { + ctx.pos++; + const val = parseUnary(ctx); + return t.value === "-" ? -val : val; + } + return parsePrimary(ctx); +} + +function parsePrimary(ctx: ParseCtx): number { + const t = ctx.tokens[ctx.pos]; + if (!t) throw new Error("Unexpected end of expression"); + + if (t.type === "number") { + ctx.pos++; + return t.value; + } + + if (t.type === "func") { + const fname = t.value; + ctx.pos++; + if ( + ctx.pos >= ctx.tokens.length || + ctx.tokens[ctx.pos].type !== "paren" || + (ctx.tokens[ctx.pos] as { value: string }).value !== "(" + ) { + throw new Error("Expected ( after function " + fname); + } + ctx.pos++; // skip ( + const args: number[] = [parseExpression(ctx)]; + while (ctx.pos < ctx.tokens.length && ctx.tokens[ctx.pos].type === "comma") { + ctx.pos++; + args.push(parseExpression(ctx)); + } + if ( + ctx.pos >= ctx.tokens.length || + ctx.tokens[ctx.pos].type !== "paren" || + (ctx.tokens[ctx.pos] as { value: string }).value !== ")" + ) { + throw new Error("Expected )"); + } + ctx.pos++; // skip ) + const fn = MATH_FNS[fname]; + if (!fn) throw new Error("Unknown function: " + fname); + return fn(...args); + } + + if (t.type === "paren" && t.value === "(") { + ctx.pos++; + const val = parseExpression(ctx); + if ( + ctx.pos >= ctx.tokens.length || + ctx.tokens[ctx.pos].type !== "paren" || + (ctx.tokens[ctx.pos] as { value: string }).value !== ")" + ) { + throw new Error("Expected )"); + } + ctx.pos++; + return val; + } + + throw new Error("Unexpected token: " + JSON.stringify(t)); +} + +function safeMathEval(expr: string): number { + const tokens = tokenizeMath(expr); + const ctx: ParseCtx = { tokens, pos: 0 }; + const result = parseExpression(ctx); + if (ctx.pos < ctx.tokens.length) { + throw new Error("Unexpected token: " + JSON.stringify(ctx.tokens[ctx.pos])); + } + return result; +} + function calculate({ expression }: { expression: string }): Promise { return new Promise((resolve) => { setTimeout(() => { try { - const sanitized = expression.replace( - /[^0-9+\-*/().%\s,Math.sqrtpowabsceilfloorround]/g, - "", - ); - const result = new Function(`return (${sanitized})`)(); + const result = safeMathEval(expression); resolve(JSON.stringify({ expression, result: Number(result) })); } catch { resolve(JSON.stringify({ expression, error: "Invalid expression" }));