Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@visactor/vtable-sheet",
"comment": "fix: prevent formula arithmetic evaluation from executing user-controlled cell content",
"type": "patch"
}
],
"packageName": "@visactor/vtable-sheet",
"email": "892739385@qq.com"
}
3 changes: 2 additions & 1 deletion docs/assets/guide/en/sheet/formula.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,10 @@ Supports common formula error handling:
- Supports nested functions, such as `=IF(SUM(A1:A5)>100, MAX(B1:B5), MIN(C1:C5))`
- Cell addresses use A1 format, such as A1, B2, C3, etc.
- Range references use a colon separator, such as A1:B10
- Arithmetic expressions are evaluated as numeric calculations only. Cell values or function results used with `+`, `-`, `*`, or `/` must be convertible to finite numbers. Non-numeric content returns a formula error and is never executed as script or code.

**The formula capabilities are not fully complete**
**The formula linkage processing, formula and sorting, and dragging rows and columns have conflicts**
**If other cell editors are manually configured, the formula capabilities will be disabled.**
**The formula capabilities will be gradually improved in future updates.**
**Welcome to participate in the contribution of the formula capabilities.**
**Welcome to participate in the contribution of the formula capabilities.**
3 changes: 2 additions & 1 deletion docs/assets/guide/zh/sheet/formula.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ VTableSheet 自身开发了 FormulaEngine 模块 作为核心的计算引擎:
- 支持嵌套函数,如 `=IF(SUM(A1:A5)>100, MAX(B1:B5), MIN(C1:C5))`
- 单元格地址使用A1格式,如A1, B2, C3等
- 区域引用使用冒号分隔,如A1:B10
- 算术表达式只会按数值进行计算;参与 `+`、`-`、`*`、`/` 运算的单元格值或函数结果需要能转换为有效数字,非数值内容会返回公式错误,不会作为脚本或代码执行

**目前公式能力并不完善**
**如公式的联动处理,公式和排序以及拖拽行列换位都有冲突**
**手动配置了其他单元格编辑器editor后,公式能力会失效**
**后续会逐步完善这些功能,也欢迎有兴趣的开发者参与贡献**
**后续会逐步完善这些功能,也欢迎有兴趣的开发者参与贡献**
33 changes: 33 additions & 0 deletions packages/vtable-sheet/__tests__/basic-formula-test.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FormulaManager } from '../src/managers/formula-manager';
import { FormulaEngine } from '../src/formula/formula-engine';

// Mock VTableSheet for testing
const mockVTableSheet = {
Expand Down Expand Up @@ -92,4 +93,36 @@ describe('Basic Formula Functionality', () => {
expect(formulas['A1']).toBe('=SUM(A1:A2)');
expect(formulas['A2']).toBe('=A1*2');
});

test('should evaluate arithmetic formulas without executing cell content as code', () => {
const engine = new FormulaEngine();
const marker = '__vtableSheetRceExecuted';
const payload = `0,globalThis.${marker}=1,0`;

delete (globalThis as any)[marker];
engine.addSheet('Sheet1', [['', '']]);
engine.setActiveSheet('Sheet1');

engine.updateSheetData('Sheet1', [[payload], ['=A1+1']]);
const batchResult = engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 0 });
expect(batchResult.error).toBeTruthy();
expect((globalThis as any)[marker]).toBeUndefined();

engine.setCellContent({ sheet: 'Sheet1', row: 0, col: 0 }, payload);
engine.setCellContent({ sheet: 'Sheet1', row: 0, col: 1 }, '=A1+1');
const singleResult = engine.getCellValue({ sheet: 'Sheet1', row: 0, col: 1 });
expect(singleResult.error).toBeTruthy();
expect((globalThis as any)[marker]).toBeUndefined();
});

test('should keep numeric arithmetic, precedence and functions working', () => {
const engine = new FormulaEngine();
engine.addSheet('Sheet1', [[2], ['=A1+1'], ['=SUM(A1:A2)+3'], ['=1+2*3'], ['=-(1+2)*3']]);
engine.setActiveSheet('Sheet1');

expect(engine.getCellValue({ sheet: 'Sheet1', row: 1, col: 0 })).toEqual({ value: 3, error: undefined });
expect(engine.getCellValue({ sheet: 'Sheet1', row: 2, col: 0 })).toEqual({ value: 8, error: undefined });
expect(engine.getCellValue({ sheet: 'Sheet1', row: 3, col: 0 })).toEqual({ value: 7, error: undefined });
expect(engine.getCellValue({ sheet: 'Sheet1', row: 4, col: 0 })).toEqual({ value: -9, error: undefined });
});
});
133 changes: 127 additions & 6 deletions packages/vtable-sheet/src/formula/formula-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1502,26 +1502,147 @@ export class FormulaEngine {
if (funcResult.error) {
return { value: null, error: `Error in function ${funcCall}: ${funcResult.error}` };
}
const numericFuncValue = this.toArithmeticNumber(funcResult.value);
if (numericFuncValue.error) {
return { value: null, error: numericFuncValue.error };
}
processedExpr =
processedExpr.slice(0, funcSpan.start) + String(funcResult.value) + processedExpr.slice(funcSpan.end + 1);
processedExpr.slice(0, funcSpan.start) +
String(numericFuncValue.value) +
processedExpr.slice(funcSpan.end + 1);
}

// 3. 处理剩余的单元格引用(包括带sheet前缀的引用,支持带引号的sheet名称)
const cellRefs = processedExpr.match(/('[^']+'!)?([A-Za-z0-9_\s一-龥]+!)?[A-Z]+[0-9]+/g) || [];
for (const cellRef of cellRefs) {
const value = this.getCellValueByA1(cellRef);
processedExpr = processedExpr.replace(cellRef, String(value));
const numericValue = this.toArithmeticNumber(value);
if (numericValue.error) {
return { value: null, error: numericValue.error };
}
processedExpr = processedExpr.replace(cellRef, String(numericValue.value));
}

// 4. 计算最终的算术表达式
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const result = Function('"use strict"; return (' + processedExpr + ')')();
return { value: result, error: undefined };
// 4. 使用白名单算术解析器计算,禁止通过 Function/eval 执行用户可控表达式
return this.evaluateBasicArithmetic(processedExpr);
} catch (error) {
return { value: null, error: 'Basic arithmetic evaluation failed' };
}
}

private toArithmeticNumber(value: unknown): { value: number; error?: string } {
if (value === null || value === undefined || value === '') {
return { value: 0, error: undefined };
}

const num = Number(value);
if (!Number.isFinite(num)) {
return { value: 0, error: 'Arithmetic operands must be numeric' };
}

return { value: num, error: undefined };
}

private evaluateBasicArithmetic(expr: string): { value: unknown; error?: string } {
try {
let index = 0;

const skipWhitespace = () => {
while (index < expr.length && /\s/.test(expr[index])) {
index++;
}
};

const parseNumber = (): number | null => {
skipWhitespace();
const match = expr.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/i);
if (!match) {
return null;
}
index += match[0].length;
return Number(match[0]);
};

const parseFactor = (): number => {
skipWhitespace();

if (expr[index] === '+') {
index++;
return parseFactor();
}
if (expr[index] === '-') {
index++;
return -parseFactor();
}
if (expr[index] === '(') {
index++;
const value = parseAdditive();
skipWhitespace();
if (expr[index] !== ')') {
throw new Error('Missing closing parenthesis');
}
index++;
return value;
}

const value = parseNumber();
if (value === null || !Number.isFinite(value)) {
throw new Error('Invalid arithmetic token');
}
return value;
};

const parseMultiplicative = (): number => {
let value = parseFactor();
while (true) {
skipWhitespace();
const operator = expr[index];
if (operator !== '*' && operator !== '/') {
break;
}
index++;
const right = parseFactor();
if (operator === '*') {
value *= right;
} else {
value /= right;
}
}
return value;
};

const parseAdditive = (): number => {
let value = parseMultiplicative();
while (true) {
skipWhitespace();
const operator = expr[index];
if (operator !== '+' && operator !== '-') {
break;
}
index++;
const right = parseMultiplicative();
if (operator === '+') {
value += right;
} else {
value -= right;
}
}
return value;
};

const result = parseAdditive();
skipWhitespace();

if (index !== expr.length || !Number.isFinite(result)) {
return { value: null, error: 'Basic arithmetic evaluation failed' };
}

return { value: result, error: undefined };
} catch {
return { value: null, error: 'Basic arithmetic evaluation failed' };
}
}

private findInnermostFunctionCallSpan(expr: string): { start: number; end: number } | null {
type Frame = { funcStart: number | null };
const stack: Frame[] = [];
Expand Down
Loading