From 41cffdfdb6e022eba9ed3b058c5e0e0d5554f4ce Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Mon, 24 Aug 2026 22:20:25 +0800 Subject: [PATCH] =?UTF-8?q?v0.1.36b:=20=E7=9C=9F=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94CSP=20unsafe-eval=20?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=20Function()=20=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E6=B1=82=E5=80=BC=EF=BC=88=E6=8E=A8=E8=BF=9B=E5=B4=A9=E6=BA=83?= =?UTF-8?q?+=E5=AE=9A=E5=88=B6=E7=99=BD=E5=B1=8F=E5=90=8C=E6=A0=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【根因】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(同版本内提交——不另立迭代) --- src/renderer/game/data/arith.ts | 124 ++++++++++++++++++ src/renderer/game/data/buildings.ts | 19 ++- .../game/engine/runtime/modManager.ts | 9 +- tests/arith-0.1.36.test.ts | 29 ++++ 4 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 src/renderer/game/data/arith.ts create mode 100644 tests/arith-0.1.36.test.ts diff --git a/src/renderer/game/data/arith.ts b/src/renderer/game/data/arith.ts new file mode 100644 index 0000000..83a74aa --- /dev/null +++ b/src/renderer/game/data/arith.ts @@ -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 +} diff --git a/src/renderer/game/data/buildings.ts b/src/renderer/game/data/buildings.ts index 1dd6395..62f58be 100644 --- a/src/renderer/game/data/buildings.ts +++ b/src/renderer/game/data/buildings.ts @@ -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 { + const entries = Object.entries(JSON.parse(exprCfg(expr) ?? '{}') as Record) 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 - return fn(L) + const out: Record = {} + 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 } diff --git a/src/renderer/game/engine/runtime/modManager.ts b/src/renderer/game/engine/runtime/modManager.ts index d9654b4..24e3a04 100644 --- a/src/renderer/game/engine/runtime/modManager.ts +++ b/src/renderer/game/engine/runtime/modManager.ts @@ -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): (level: number return (L: number) => { const out: Record = {} 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 } diff --git a/tests/arith-0.1.36.test.ts b/tests/arith-0.1.36.test.ts new file mode 100644 index 0000000..6572e86 --- /dev/null +++ b/tests/arith-0.1.36.test.ts @@ -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) + }) +})