#!/usr/bin/env node /** * verify-emits-parse.mjs — defineEmits 解析的安全性与行为等价性验证(零依赖)。 * * 背景(实测缺陷 P2): * 修复前 site/app.js 用 * var emitArr = (new Function('return ' + mEmits[1]))(); * 求值 defineEmits 的数组字面量。任何能改 frameworks/*.vue* 的人(内部威胁 / * 投毒 PR)只要写 defineEmits([1,globalThis.__pwned = 'x']),打开该组件详情页 * 就会在文档站同源下执行任意 JS。实测: * MATCH "[1,globalThis.__pwned = \"executed-arbitrary-js\"]" -> 已实际执行 * * 本脚本做三件事: * 1) 静态断言 site/app.js 里不再存在动态字符串求值点([COUNTEREXAMPLE],修复前必失败); * 2) 从 site/app.js 中抽出修复后的解析函数(parseStringLiteralArray / isHexRun), * 与「修复前的求值实现」在全部 frameworks/*.vue(约 158 个)上逐一比对抽取结果, * 断言不一致数为 0 —— 证明替换没有改变真实行为; * 3) 断言恶意字面量在新解析下既不执行、也不抛错;同时用修复前实现做阳性对照, * 证明这组恶意输入「真的可执行」,即反例断言有区分力。 * * 说明:所有「动态求值」都发生在 node:vm 的独立沙箱 context 内(刻意保留的修复前对照 * 实现,等价于修复前的 return + 函数构造器),被测文件 site/app.js 自身不含任何求值点。 * * 运行:node tools/verify-emits-parse.mjs * 退出码:0 = 全部通过;1 = 有断言失败。 */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createContext, runInContext } from 'node:vm'; const ROOT = join(import.meta.dirname, '..'); const APP_PATH = join(ROOT, 'site', 'app.js'); const FW_DIR = join(ROOT, 'frameworks'); const app = readFileSync(APP_PATH, 'utf8'); let failed = 0; let passed = 0; const results = []; function check(name, ok, detail = '') { results.push({ name, ok, detail }); if (ok) passed++; else failed++; } /* ---------- 1. 静态断言 ---------- */ const evalSink = app.match(/\bnew\s+Function\b|\beval\s*\(/); check('[COUNTEREXAMPLE] site/app.js 无动态字符串求值点(修复前第 1229 行是 new Function(...))', evalSink === null, evalSink ? `found ${JSON.stringify(evalSink[0])} @ offset ${evalSink.index}` : 'clean'); check('defineEmits 的匹配正则保持原样', app.includes(String.raw`/defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/`)); check('$emit( 的扫描正则保持原样', app.includes(String.raw`/\$emit\(\s*['"]([a-zA-Z0-9_:-]+)['"]/g`)); check('新的安全解析被真实调用', app.includes('var emitArr = mEmits ? parseStringLiteralArray(mEmits[1]) : null;')); /* ---------- 2. 从 site/app.js 抽出解析函数(被测代码 = 实际部署的文本) ---------- */ function skipString(src, i) { const quote = src[i]; i++; while (i < src.length) { if (src[i] === '\\') { i += 2; continue; } if (src[i] === quote) return i + 1; i++; } return i; } function extractFunction(src, startMarker) { const start = src.indexOf(startMarker); if (start < 0) return null; let depth = 0, seen = false, i = start; while (i < src.length) { const ch = src[i]; if (ch === '/' && src[i + 1] === '/') { const nl = src.indexOf('\n', i); if (nl < 0) return null; i = nl; continue; } if (ch === '/' && src[i + 1] === '*') { const c = src.indexOf('*/', i + 2); if (c < 0) return null; i = c + 2; continue; } if (ch === "'" || ch === '"' || ch === '`') { i = skipString(src, i); continue; } if (ch === '{') { depth++; seen = true; i++; continue; } if (ch === '}') { depth--; if (seen && depth === 0) return src.slice(start, i + 1); i++; continue; } i++; } return null; } const helpers = [ extractFunction(app, 'function isHexRun(s, n) {'), extractFunction(app, 'function parseStringLiteralArray(literal) {') ]; const region = helpers.every(Boolean) ? helpers.join('\n') : null; check('从 site/app.js 抽出解析函数源码', region !== null, region ? `bytes=${region.length}(isHexRun + parseStringLiteralArray)` : '未找到解析函数'); if (!region) { for (const r of results) console.log(`[emits] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); console.error('\n[emits] 无法继续:解析函数抽取失败'); process.exit(1); } check('抽取结果含 parseStringLiteralArray 定义', /function parseStringLiteralArray\(/.test(region)); const sandbox = createContext({}); runInContext(region, sandbox, { filename: 'site/app.js#emits-parser' }); const parseStringLiteralArray = sandbox.parseStringLiteralArray; check('解析函数可在沙箱中独立加载(无外部依赖)', typeof parseStringLiteralArray === 'function'); /* ---------- 3. 与修复前实现逐一比对 ---------- */ const DEFINE_EMITS_RE = /defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/; const DEFINE_EMITS_RE_G = /defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/g; /* 修复前的实现:把字面量当作代码求值(在沙箱内执行,等价于 return + 函数构造器)。 这是刻意保留的对照实现,只用于证明反例可执行、以及与真实文件的行为一致性。 */ function legacyParse(literal, ctx) { return runInContext(literal, ctx); } /* 修复后的实现:调用被测文件里抽出的解析函数 */ function safeParse(literal) { const arr = parseStringLiteralArray(literal); return Array.isArray(arr) ? arr : null; } /* 复刻 site/app.js「2. 提炼 Emits」分支的贡献(desc / 去重逻辑两版一致, 故唯一变量就是解析方式;$emit( 扫描与 JSX 回退不在此分支内,未改动) */ function defineEmitsNames(code, parser, ctx) { const m = code.match(DEFINE_EMITS_RE); if (!m) return []; let arr; try { arr = parser(m[1], ctx); } catch { return []; // 修复前实现 catch (err) {} 的等价行为 } if (!Array.isArray(arr)) return []; const names = []; try { arr.forEach((e) => { const desc = e.indexOf('update:') === 0 ? 'model' : 'event'; // 与 app.js 同构 if (!names.some((x) => x.name === e)) names.push({ name: e, desc }); }); } catch { /* 修复前 forEach 中途抛错时保留已 push 的部分 */ } return names; } const vueFiles = readdirSync(FW_DIR).filter((f) => f.endsWith('.vue')).sort(); let withLiteral = 0; let occurrences = 0; let entries = 0; let mismatches = 0; const mismatchDetail = []; const sweepCtx = createContext({}); for (const file of vueFiles) { const code = readFileSync(join(FW_DIR, file), 'utf8'); occurrences += (code.match(DEFINE_EMITS_RE_G) || []).length; const oldNames = defineEmitsNames(code, legacyParse, sweepCtx); const newNames = defineEmitsNames(code, safeParse, sweepCtx); if (oldNames.length || newNames.length) { withLiteral++; entries += newNames.length; } if (JSON.stringify(oldNames) !== JSON.stringify(newNames)) { mismatches++; if (mismatchDetail.length < 5) { mismatchDetail.push(`${file}: old=${JSON.stringify(oldNames)} new=${JSON.stringify(newNames)}`); } } } check(`frameworks/*.vue 全量比对(${vueFiles.length} 文件)不一致数为 0`, mismatches === 0, `含 defineEmits 数组字面量的文件 ${withLiteral} / 字面量 ${occurrences} 处 / 事件名 ${entries} 条 / 不一致 ${mismatches}`); check('扫描到的 Vue 实现文件数量与预期一致(158)', vueFiles.length === 158, `count=${vueFiles.length}`); if (mismatchDetail.length) mismatchDetail.forEach((d) => console.log(`[emits] mismatch ${d}`)); /* ---------- 4. 恶意输入:不执行 / 不抛错(含修复前阳性对照) ---------- */ const HOSTILE = [ ['defineEmits([1,globalThis.__pwned = "executed-arbitrary-js"])', '数字元素 + 赋值表达式'], ['defineEmits([globalThis.__pwned = 1])', '纯赋值表达式元素'], ["defineEmits(['a' + (globalThis.__pwned = 1)])", '字符串拼接注入'], ['defineEmits([(globalThis.__pwned = 1)])', '括号包裹表达式'], ["defineEmits(['a', () => { globalThis.__pwned = 1 }])", '箭头函数元素'], ['defineEmits([`${globalThis.__pwned = 1}`])', '模板字符串元素'] ]; const controlCtx = createContext({}); let controlExecuted = 0; for (const [src, label] of HOSTILE) { const m = src.match(DEFINE_EMITS_RE); if (!m) continue; try { legacyParse(m[1], controlCtx); } catch { /* 旧实现内部 catch */ } if (runInContext('globalThis.__pwned', controlCtx) !== undefined) { controlExecuted++; runInContext('delete globalThis.__pwned', controlCtx); } } check('阳性对照:修复前实现确实会执行这些恶意输入(证明反例有区分力)', controlExecuted > 0, `${controlExecuted}/${HOSTILE.length} 个恶意输入在旧实现下实际执行`); const hostileCtx = createContext({}); runInContext(region, hostileCtx, { filename: 'site/app.js#emits-parser' }); const safeHostile = hostileCtx.parseStringLiteralArray; let executed = 0; let threw = 0; const hostileOutcome = []; for (const [src, label] of HOSTILE) { const m = src.match(DEFINE_EMITS_RE); if (!m) { hostileOutcome.push(`${label}: 正则未匹配(不进入解析)`); continue; } let out; try { out = safeHostile(m[1]); } catch (e) { threw++; hostileOutcome.push(`${label}: THREW ${e.message}`); continue; } if (out !== null) hostileOutcome.push(`${label}: 返回了非 null -> ${JSON.stringify(out)}`); else hostileOutcome.push(`${label}: null(拒绝解析)`); } if (runInContext('typeof globalThis.__pwned', hostileCtx) !== 'undefined') executed++; check('新实现不执行任何恶意输入(globalThis.__pwned 保持 undefined)', executed === 0); check('新实现对恶意输入不抛错(全部返回 null)', threw === 0, hostileOutcome.join(' | ')); /* ---------- 5. 真实写法必须仍能抽取 ---------- */ /* 入参是 defineEmits(...) 里捕获到的**完整数组字面量**(含方括号),与 app.js 的调用一致 */ const positive = [ ["['update:modelValue','change']", ['update:modelValue', 'change'], '真实文件的单引号写法'], ['["click","change"]', ['click', 'change'], '双引号写法'], ["[ 'a' , 'b' ]", ['a', 'b'], '多余空白'], ["['a',]", ['a'], '尾逗号'], ["[ 'don\\'t' ]", ["don't"], '转义单引号按字面量还原'], ['["a\\u0062c"]', ['abc'], '\\u 转义还原'], ['["a\\x62"]', ['ab'], '\\x 转义还原'], ['[]', [], '空数组字面量(app.js 的正则不取,解析器本身为空数组)'], ["['a', 1]", null, '混入非字符串 -> 整体拒绝'], ["['a' + 'b']", null, '表达式 -> 整体拒绝'], ['[`t`]', null, '模板字符串 -> 整体拒绝'], ["['a', ...b]", null, '展开 -> 整体拒绝'], ["[ 'a\\qb' ]", null, '字符串内未识别转义 -> 整体拒绝'] ]; let positiveFail = 0; const positiveSeen = []; for (const [literal, expected, label] of positive) { const got = safeParse(literal); const ok = JSON.stringify(got) === JSON.stringify(expected); if (!ok) positiveFail++; positiveSeen.push(`${label}=${ok ? 'OK' : `FAIL(${JSON.stringify(got)})`}`); } check('单引号 / 双引号 / 空白 / 尾逗号 / 转义的真实写法仍正确抽取', positiveFail === 0, positiveSeen.join(' ')); /* ---------- 5.5 构建期同源副本交叉校验(存在才校验;缺失只提示,不算通过) ---------- */ const LIB_PATH = join(ROOT, 'tools', 'lib', 'parse-string-literal-array.mjs'); if (existsSync(LIB_PATH)) { const libMod = await import(pathToFileURL(LIB_PATH).href); const libParse = libMod.parseStringLiteralArray; check('构建期副本 tools/lib/parse-string-literal-array.mjs 导出解析函数', typeof libParse === 'function'); let libMismatch = 0; const libDetail = []; for (const file of vueFiles) { const m = readFileSync(join(FW_DIR, file), 'utf8').match(DEFINE_EMITS_RE); if (!m) continue; let libOut; try { const r = libParse(m[1]); libOut = Array.isArray(r) ? r : null; } catch (e) { libOut = `THREW ${e.message}`; } if (JSON.stringify(safeParse(m[1])) !== JSON.stringify(libOut)) { libMismatch++; if (libDetail.length < 3) libDetail.push(`${file}: app.js=${JSON.stringify(safeParse(m[1]))} lib=${JSON.stringify(libOut)}`); } } check('构建期副本与 site/app.js 在 158 个 .vue 上抽取结果一致(构建/运行不分叉)', libMismatch === 0, `不一致 ${libMismatch}${libDetail.length ? ' :: ' + libDetail.join(' | ') : ''}`); let libExecuted = 0; for (const [src] of HOSTILE) { const m = src.match(DEFINE_EMITS_RE); if (!m) continue; try { libParse(m[1]); } catch { /* 抛错由下面的断言覆盖 */ } if (globalThis.__pwned !== undefined) { libExecuted++; delete globalThis.__pwned; } } check('构建期副本同样不执行恶意输入', libExecuted === 0); } else { console.log('[emits] INFO 未发现 tools/lib/parse-string-literal-array.mjs(构建期同源副本),跳过交叉校验'); } /* ---------- 6. 真实文件抽样:抽到的事件名必须与源文件字面量一一对应 ---------- */ const sampleFile = vueFiles.find((f) => DEFINE_EMITS_RE.test(readFileSync(join(FW_DIR, f), 'utf8'))); const sampleCode = readFileSync(join(FW_DIR, sampleFile), 'utf8'); const sampleLiteral = sampleCode.match(DEFINE_EMITS_RE)[1]; const sampleOut = defineEmitsNames(sampleCode, safeParse, sweepCtx); check(`抽样 ${sampleFile} 抽到的事件名与源字面量一致`, sampleOut.length > 0 && sampleOut.every((x) => sampleLiteral.includes(`'${x.name}'`) || sampleLiteral.includes(`"${x.name}"`)), `literal=${sampleLiteral} -> ${JSON.stringify(sampleOut.map((x) => x.name))}`); /* ---------- 汇总 ---------- */ for (const r of results) { console.log(`[emits] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); } if (failed) { console.error(`\n[emits] ${failed} check(s) failed / ${passed} passed`); process.exit(1); } console.log(`\n[emits] OK — ${passed} checks passed;${vueFiles.length} 个 .vue 文件双实现比对不一致 0`); process.exit(0);