v0.1.36b: 真根因修复——CSP unsafe-eval 拦截 Function() 字符串求值(推进崩溃+定制白屏同根)
【根因】Electron 打包 renderer CSP 禁 unsafe-eval;buildings.bonusOf / exprToTable / modManager.booleanExprTable
三处用 Function()+正则白名单字符串求值——任何调用点抛 EvalError:
·推进崩溃=每月月率 bonusOf('juling','expBonus') 等(MarketPanel craft 表头同源)
·定制白屏=craft 页渲染行内 bonusOf('danfang','craftChance') → React 崩 → 只剩背景
【修复】全新 arith.ts 纯解析器(令牌化+递归下降:数字/L/四则/括号/幂/负号——白名单天然,字母函数名一律 NaN),
三处替换零 Function/eval;非法输入 NaN 不抛(调用方 Number.isFinite 跳过)
【测试】arith 4 例(四则/幂右结合/负号/除零/非法输入防注入)+全量 7011 绿
【版本】0.1.36(同版本内提交——不另立迭代)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 安全算术表达式求值(0.1.36:CSP 禁止 unsafe-eval —— Function()/eval 一律改为纯解析器)
|
||||
*
|
||||
* 支持:数字(整数/小数)、变量 L(等级)、+ - * / 、括号、幂 ^
|
||||
* 白名单:解析器只认这些——任何字母(除 L)/函数名/属性访问都不会被解析,报 NaN 即跳过。
|
||||
* 用法:arith('0.5 + 0.1*L', 3) === 0.8
|
||||
*/
|
||||
|
||||
interface Tok { t: 'num' | 'op' | 'lparen' | 'rparen' | 'var'; v: number | string }
|
||||
|
||||
function tokenize(src: string): Tok[] | null {
|
||||
const out: Tok[] = []
|
||||
let i = 0
|
||||
const s = src.replace(/\s+/g, '')
|
||||
while (i < s.length) {
|
||||
const c = s[i]
|
||||
if (c >= '0' && c <= '9' || c === '.') {
|
||||
let j = i
|
||||
while (j < s.length && ((s[j] >= '0' && s[j] <= '9') || s[j] === '.')) j++
|
||||
const num = Number(s.slice(i, j))
|
||||
if (!Number.isFinite(num)) return null
|
||||
out.push({ t: 'num', v: num })
|
||||
i = j
|
||||
} else if (c === 'L') {
|
||||
out.push({ t: 'var', v: 'L' })
|
||||
i++
|
||||
} else if (c === '+' || c === '-' || c === '*' || c === '/' || c === '^') {
|
||||
out.push({ t: 'op', v: c })
|
||||
i++
|
||||
} else if (c === '(') {
|
||||
out.push({ t: 'lparen', v: '(' })
|
||||
i++
|
||||
} else if (c === ')') {
|
||||
out.push({ t: 'rparen', v: ')' })
|
||||
i++
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 递归下降求值(后进先出操作符;幂右结合、乘除左结合、余运算符优先级遵循通常数学惯例) */
|
||||
function evalTokens(toks: Tok[], start: number, end: number, L: number): { v: number; next: number } | null {
|
||||
const peek = (p: number) => (p < toks.length ? toks[p] : null)
|
||||
|
||||
// 加法/减法层:最低优先级
|
||||
const evalAdd = (p: number): { v: number; next: number } | null => {
|
||||
let left = evalMul(p)
|
||||
if (!left) return null
|
||||
while (true) {
|
||||
const t = peek(left.next)
|
||||
if (t && t.t === 'op' && (t.v === '+' || t.v === '-')) {
|
||||
const right = evalMul(left.next + 1)
|
||||
if (!right) return null
|
||||
left = { v: t.v === '+' ? left.v + right.v : left.v - right.v, next: right.next }
|
||||
} else break
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// 乘除层
|
||||
const evalMul = (p: number): { v: number; next: number } | null => {
|
||||
let left = evalPow(p)
|
||||
if (!left) return null
|
||||
while (true) {
|
||||
const t = peek(left.next)
|
||||
if (t && t.t === 'op' && (t.v === '*' || t.v === '/')) {
|
||||
const right = evalPow(left.next + 1)
|
||||
if (!right) return null
|
||||
if (t.v === '/' && right.v === 0) return null
|
||||
left = { v: t.v === '*' ? left.v * right.v : left.v / right.v, next: right.next }
|
||||
} else break
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// 幂层(右结合)
|
||||
const evalPow = (p: number): { v: number; next: number } | null => {
|
||||
const base = evalUnary(p)
|
||||
if (!base) return null
|
||||
const t = peek(base.next)
|
||||
if (t && t.t === 'op' && t.v === '^') {
|
||||
const exp = evalPow(base.next + 1)
|
||||
if (!exp) return null
|
||||
return { v: Math.pow(base.v, exp.v), next: exp.next }
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// 负号/括号层
|
||||
const evalUnary = (p: number): { v: number; next: number } | null => {
|
||||
const t = peek(p)
|
||||
if (!t) return null
|
||||
if (t.t === 'op' && t.v === '-') {
|
||||
const inner = evalUnary(p + 1)
|
||||
if (!inner) return null
|
||||
return { v: -inner.v, next: inner.next }
|
||||
}
|
||||
if (t.t === 'lparen') {
|
||||
const inner = evalAdd(p + 1)
|
||||
if (!inner) return null
|
||||
const close = peek(inner.next)
|
||||
if (!close || close.t !== 'rparen') return null
|
||||
return { v: inner.v, next: inner.next + 1 }
|
||||
}
|
||||
if (t.t === 'num') return { v: t.v as number, next: p + 1 }
|
||||
if (t.t === 'var') return { v: L, next: p + 1 }
|
||||
return null
|
||||
}
|
||||
|
||||
const outer = evalAdd(start)
|
||||
if (!outer || outer.next !== end) return null
|
||||
return outer
|
||||
}
|
||||
|
||||
/** 安全求值:非法/未消费完 → NaN(调用方 Number.isFinite 判定) */
|
||||
export function arith(expr: string, L = 0): number {
|
||||
const toks = tokenize(expr ?? '')
|
||||
if (!toks || toks.length === 0) return Number.NaN
|
||||
const r = evalTokens(toks, 0, toks.length, L)
|
||||
if (!r) return Number.NaN
|
||||
return r.v
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { arith } from './arith'
|
||||
|
||||
export interface BuildingDef {
|
||||
id: string
|
||||
name: string
|
||||
@@ -126,12 +128,16 @@ export function buildingById(id: string): BuildingDef | undefined {
|
||||
export function listBuildingDefs(): BuildingDef[] {
|
||||
return [...Object.values(BUILDINGS), ...DYN_BUILDINGS.values()]
|
||||
}
|
||||
/** 表达式 → 月产/升级费函数('10 * L'/'80 * 1.7^(L-1)' 白名单求值) */
|
||||
/** 表达式 → 月产/升级费函数('10 * L'/'80 * 1.7^(L-1)' 白名单求值——0.1.36 起纯解析器) */
|
||||
export function exprToTable(expr: string): (level: number) => Record<string, number> {
|
||||
const entries = Object.entries(JSON.parse(exprCfg(expr) ?? '{}') as Record<string, string>)
|
||||
return (L: number) => {
|
||||
const body = Object.entries(JSON.parse(exprCfg(expr) ?? '{}')).map(([k, v]) => `"${k}":${v}`).join(',')
|
||||
const fn = Function('L', `"use strict"; return { ${body} };`) as (l: number) => Record<string, number>
|
||||
return fn(L)
|
||||
const out: Record<string, number> = {}
|
||||
for (const [k, v] of entries) {
|
||||
const val = arith(String(v), L)
|
||||
if (Number.isFinite(val)) out[k] = Math.round(val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
function exprCfg(_e: string): string | null {
|
||||
@@ -152,8 +158,7 @@ export function bonusOf(id: string, key: string, level: number): number {
|
||||
const def = BUILDINGS[id]
|
||||
const expr = def?.extra?.[key]
|
||||
if (!expr) return 0
|
||||
const sanitized = expr.replace(/L/g, String(level))
|
||||
if (!/^[0-9.\s+\-*/()]+$/.test(sanitized)) return 0
|
||||
const v = Function(`"use strict";return (${sanitized})`)() as number
|
||||
// 0.1.36 CSP safe:纯解析器——不再 Function()(unsafe-eval 被 Electron 打包版拦截)
|
||||
const v = arith(expr, level)
|
||||
return Number.isFinite(v) ? v : 0
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventDef } from '../../data/events'
|
||||
import { findEvent } from './Systems/events'
|
||||
import { World } from './World'
|
||||
import { WorldGenPools } from '../sim/worldgen'
|
||||
import { arith } from '../../data/arith'
|
||||
import { pack as dataPack } from '../../data/registry'
|
||||
import { removeCalamityByName, overrideWorldNum, resetWorldNum } from '../sim/worldsim-data'
|
||||
import { removePillRecipe, removeForgeRecipe } from '../../data/items'
|
||||
@@ -54,12 +55,8 @@ function booleanExprTable(expr: string | Record<string, string>): (level: number
|
||||
return (L: number) => {
|
||||
const out: Record<string, number> = {}
|
||||
for (const [k, v] of entries) {
|
||||
const sanitized = String(v).replace(/L/g, String(L))
|
||||
if (!/^[0-9.\s+\-*/()^]+$/.test(sanitized)) continue
|
||||
try {
|
||||
const val = Function(`"use strict";return (${sanitized.replace(/\^/g, '**')})`)() as number
|
||||
if (Number.isFinite(val)) out[k] = Math.round(val)
|
||||
} catch { /* 非法表达式跳过 */ }
|
||||
const val = arith(String(v), L)
|
||||
if (Number.isFinite(val) && val >= 0) out[k] = Math.round(val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { arith } from '../src/renderer/game/data/arith'
|
||||
|
||||
describe('0.1.36 arith 纯解析器(CSP safe——零 Function/eval)', () => {
|
||||
it('基础四则与变量', () => {
|
||||
expect(arith('0.5 + 0.1*L', 3)).toBeCloseTo(0.8)
|
||||
expect(arith('10 * L', 2)).toBe(20)
|
||||
expect(arith('4 * L ^ 2', 3)).toBe(36)
|
||||
expect(arith('80 * 1.6^(L-1)', 3)).toBeCloseTo(204.8, 3)
|
||||
expect(arith('(1 + 2) * 3')).toBe(9)
|
||||
})
|
||||
it('非法输入→NaN(不抛异常)', () => {
|
||||
expect(Number.isNaN(arith(''))).toBe(true)
|
||||
expect(Number.isNaN(arith('L + foo'))).toBe(true)
|
||||
expect(Number.isNaN(arith('1 + '))).toBe(true)
|
||||
expect(Number.isNaN(arith('alert(1)'))).toBe(true)
|
||||
expect(Number.isNaN(arith('0.5 + 0.1*L^', 3))).toBe(true)
|
||||
expect(Number.isNaN(arith('L/0 +1', 3))).toBe(true)
|
||||
})
|
||||
it('负号/右结合幂/除零', () => {
|
||||
expect(arith('-3 + 5')).toBe(2)
|
||||
expect(arith('2 ^ 3 ^ 2')).toBe(512)
|
||||
expect(Number.isNaN(arith('1/0'))).toBe(true)
|
||||
})
|
||||
it('与旧 bonusOf 语义一致(0.5+0.1*L @3→0.8)', () => {
|
||||
expect(arith('0.5 + 0.1*L', 3)).toBeCloseTo(0.8)
|
||||
expect(arith('0.35*L', 2)).toBeCloseTo(0.7)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user