项目:html5-dataviz | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本项目没有后端、没有登录态、没有用户输入的持久化存储,因此传统意义上的"权限设计"(认证/鉴权/越权)在这里不存在——README.md 第 50 行的表述是理解本章的钥匙:"运行时零外链……(内网部署硬前提)"。真正构成安全面的,是三类代码执行与资源加载问题:
optionCode/componentCode 是官方示例脚本字符串,经 new Function 或 SFC 沙箱编译在浏览器端执行——这是全站最大的攻击面,虽然代码来源是"项目自带的静态 JSON"而非用户输入;src/utils/sfc-modules.ts)、UMD 脚本加载器(src/chart-examples/loadScript.ts)决定了"谁可以被加载执行";信任模型上,项目把 src/data/charts/*.json、src/chart-examples/**、public/** 视为受信内容(与代码同源发布、一起过 code review),把"运行时是否引入了这些内容之外的东西"视为防线所在。这个模型是务实的:它不防"仓库本身被投毒"(那需要供应链安全手段),但防"迁移/生成过程中混入的脏代码逃逸出预期行为"。
危险模式校验位于 src/utils/sfcRuntime.ts 的安全验证区,常量 DANGEROUS_RUNTIME_PATTERNS 收录 9 条正则,validateRuntimeBody 在拼接完运行时代码体(runtimeBody)后、执行前统一校验。任一命中即抛错(compileSfcToComponent 路径,错误进入渲染失败态)或告警(warnOnly 路径):
| # | 正则模式 | 拦截意图 |
|---|---|---|
| 1 | \beval\s*\( | 直接 eval 调用 |
| 2 | \bnew\s+Function\s*\( | 二次动态构造函数(嵌套逃逸沙箱) |
| 3 | \bFunction\s*\(\s*['"]` | Function('...') 形式的变体 |
| 4 | \bWebSocket\s*\( | 建立任意 WebSocket 连接 |
| 5 | \bdocument\.write\s*\( | 向文档注入任意 HTML |
| 6 | `\blocation\s*.\s*(?:href | assign |
| 7 | \bwindow\s*\.\s*location\s*= | window 级重定向 |
| 8 | `\btop\s*.\s*(?:location | href)` |
| 9 | `\bparent\s*.\s*(?:location | href)` |
配套约束有两条:RUNTIME_BODY_MAX_LENGTH = 500000(50 万字符上限,注释"编译产物过大,可能存在异常输入"——兜住意外膨胀的输入而非蓄意攻击);执行体消毒(executeRuntimeBody)在真正 new Function 前把 eval 词替换为 __unsafe_eval__(值为 undefined)、arguments 替换为 __unsafe_arguments__,并在函数体顶部声明 var parent=undefined, top=undefined, opener=undefined 与 var ROOT_PATH="/echarts-data"——前者让"访问 parent/top"从真实窗口引用变成 undefined,后者让官方脚本的 ROOT_PATH 相对取数语义在沙箱内成立。
静态 import 经 rewriteScriptImports 改写为 __deps__ 注入表取值,依赖表在编译期由 collectModuleDeps → ensureModule 构建——没进依赖表的源在编译期就会走到 MissingModuleStub 或空对象。动态 import 经 rewriteDynamicImports 改写为 __dynamicImport__(source),其实现(runtimeBody 内联)用 Object.prototype.hasOwnProperty.call(__deps__, source) 做闸门:未注册的动态导入直接 Promise.reject(new Error('[sfcRuntime] 不允许的动态导入'))。静态与动态两条路径都以"依赖收集阶段的结果"为唯一放行依据,即白名单的执行点是单一的。
必须明确:这套拦截是行为护栏而非安全边界。九条正则均为字面模式匹配,无法对抗蓄意混淆(例如字符串拼接后再经间接调用、globalThis 上的属性访问、正则未覆盖的 DOM API),new Function 执行本身也意味着代码拥有与宿主页面同源的完整能力。项目自己的注释也从未宣称"沙箱防攻击"——拦截的目标是拦截迁移示例中的历史残留与意外危险调用(官方 demo 确实出现过 document.write、location.href 类代码),并在错误信息中给出可定位的失败态。若未来要承载第三方用户提交的 SFC,需换用 Worker + CSP 的真隔离方案(见第 8 节)。
src/utils/sfc-modules.ts 的解析优先级链为:moduleCache → 资源导入检测 → npm/internal 注册表 → 相对路径解析 → @/ 别名兜底 → MissingModuleStub。三层"允许清单"如下:
| 层 | 内容 | 规模 |
|---|---|---|
npmModuleRegistry | vue、echarts、echarts-gl、echarts/extension/bmap/bmap、@antv/g2plot、three、gsap、pinia、vue-router、three/examples/jsm/controls/OrbitControls.js、three/examples/jsm/loaders/SVGLoader.js | 11 项 |
internalModuleRegistry | @/components/common/code/CommonCodeBlock.vue | 1 项 |
aliasModuleMap(glob 自动发现) | @/components/**(vue/ts/js)、@/utils/**、@/composables/**、@/views/svg-lessons/**、@/views/svg-lessons/by-doc/** | 按文件系统动态生成 |
三层的注释各自记录了裁剪决策:npm 表注释说明"主项目注册表中与本站无关的条目(animejs/fabric/pixi.js/useCanvas/svg-lessons 课程模块)已按游戏拆分 D3 原则裁剪,缺失模块走 MissingModuleStub 兜底";glob 层注释解释课程 SFC 会以相对路径或 @/components、@/utils、@/composables 别名引用兄弟模块,固定白名单覆盖不了,必须按需自动发现。glob 自动发现值得注意的是它的边界就是 src 下这几个目录——@/chart-examples、@/views/bigscreen(svg-lessons 除外)不在 glob 表里,沙箱内代码无法借 @/ 别名摸到示例源码与大屏实现,这既是性能决策(不把 1617 个 SFC 纳入 glob 索引)也是事实上的加载边界。
资源导入(.jpg/.png/.mp4/.woff2/.json 等扩展名,ASSET_EXTENSIONS 正则)不走模块解析,直接转成 BASE_URL + 去掉 @/ 前缀的路径 字符串返回——沙箱内的图片/字体引用最终落在 public 或 src 静态资产上,不会发起外链。加载失败的模块不写 moduleCache(注释:网络抖动场景重试可能成功),返回空对象;解析失败但确实缺失的模块返回 MissingModuleStub——一个带橙色虚线边框、文案"⚠ 组件缺失:{source}"的占位组件,注释强调"既避免静默空白 + 控制台告警刷屏,也诚实标记缺失来源"。这两条失败路径的区分(可重试 vs 永久缺失)与 datasetCache 的失败清缓存口径一致,是全站统一的缓存卫生学。
src/chart-examples/loadScript.ts 是全站唯一在运行时注入 script 标签的通道,消费方有三处:EChartDemo 的 ctx.loadScript 与 props.scripts、ChartExampleRenderer 的扩展库预载(ecStat/ecSimpleTransform/ecSimpleOptionPlayer)、以及 ChartExampleRenderer 求值上下文注入的 loadScript 形参。它的边界特性有三:
cache: Map<string, Promise<void>> 以完整 src 为键,同一 src 只加载一次,并发调用共享同一 Promise;window.define,导致 UMD 库(ecStat 等)走 define.amd 分支而不挂全局变量。加载期间临时把 window.define 置 undefined,onload 后恢复原值(或 delete)。这是全站最"贴着浏览器怪癖写"的一段代码,onerror 分支也恢复了 define,保证失败不留污染;/echarts-data/lib/ 下的本地路径,但这属于约定而非强制——任何沙箱代码若拿到 loadScript 引用,即可注入任意 src 的脚本(且 script 注入不受 sfcRuntime 的危险模式校验管辖,因为那是另一个执行阶段)。在"静态资产受信"的信任模型下这是可接受的,但值得在第 8 节隐患清单中记一笔。three 生态的"扩展加载"走的是另一条路:不在运行时注 script,而是构建期 alias。vite.config.ts 把 three 官方源码里裸写的 jsdelivr CDN import(opentype.js、mp4box、lottie-web、polyslice、three-raycaster-helper 五条)重写到 npm 包,把 ecsy.module.js、AmmoPhysics.js 重写到 src/chart-examples/three/vendor/ 本地副本(AmmoPhysics 的 wasm 运行时字符串 alias 拦不住,直接改其源码副本的 AMMO_PATH 指向 public/vendor-ammo/)。WebGL 上下文本身的管理(forceContextLoss 防撞浏览器 8~16 个 context 上限)见 10 篇第 5 节,此处不展开。
71 个兜底条目中有 63 个(54 echarts option/api-async + 9 antv antv-config 的求值部分)走的是 ChartExampleRenderer 里未经沙箱消毒的 new Function,与 SFC 沙箱形成两条防护等级不同的执行链,对比见表:
| 维度 | optionCode 求值(renderEchartsOption/renderAntvConfig) | SFC 沙箱(sfcRuntime) |
|---|---|---|
| 执行方式 | 裸 new Function(9 形参 / console / 函数体) | 消毒后 new Function(parent/top/opener 屏蔽、eval/arguments 替换) |
| 危险模式校验 | 无 | 9 条正则 + 50 万字符上限 |
| 模块加载 | 无(脚本不可 import) | 白名单 + __dynamicImport__ 闸门 |
| 输出来源 | src/data/charts/*.json 的代码字段(slim 脚本保留) | componentCode(8 条 antv)或外部传入 |
| 设计理由 | 官方脚本需真实 DOM/echarts 上下文,走全功能路径 | 独立 Vue 组件,天然适合编译执行 |
这种"双等级"是刻意取舍:optionCode 脚本以 myChart.setOption、fetchJson 等宿主注入的能力为中心,做消毒收益低(拦不住真正的恶意)而破坏面大(正则误杀合法脚本);SFC 是完整程序,才有模块与行为两个维度可管。求值链路的上下文形参清单(echarts/myChart/setOption/fetchJson/loadScript/registerMap/registerTransform/ROOT_PATH/console)本身就是其暴露面的完整枚举——脚本拿不到 document/window 之外的注入,但也拦不住它直接访问全局(同源执行)。配套的缓解是 unhandledrejection 桥接让异步失败可见、以及 gen 比对 dispose 防泄漏,属于"出了事看得见"而非"出不了事"。
外链清零是硬约束,scripts/smoke.mjs 第 19 行的 EXTERNAL_HOST = /jsdelivr\.net|aliyun\.com|youbao\.xyz|alipayobjects\.com/ 是它的机器可读形式。实现分四层:
vite.config.ts alias 表把五条 jsdelivr CDN 静态 import 重写到 node_modules 本地包(见第 4 节),注释明确动机"会被 rollup 提升进 ExampleScene 共享 chunk,导致几乎所有示例页面发外链";/echarts-data/lib/;public/ 携带 echarts-data(71MB)、map-geojson(9.1MB,含 china/world 与 34 个省级 GeoJSON)、三大 three 资产目录(合计约 812MB)、vendor-ammo/basis/draco/rhino3dm 解码器副本;index.html 的 Google Fonts(Space Grotesk/Inter/JetBrains Mono 三族,preconnect + stylesheet)。这是有意的渐进增强——字体加载失败回落系统字体,不阻塞任何功能;smoke 的 EXTERNAL_HOST 正则刻意不含 googleapis.com,说明"零外链"的精确语义是零功能性外链(脚本/数据/模型),而非字面意义的一个请求都不发。内网部署若要连这条也清零,需自行本地化字体并改 index.html(目前仓库内无字体本地化资产,⚠️ 待确认是否有配套离线字体包)。另一处与内容安全相关的实现:src/views/bigscreen/sc-datav-demo2/components/Chart4Content.vue 在渲染动态 SVG 前经 DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) 清洗——全仓库唯一一处使用 dompurify 的地方,说明项目对"字符串进 innerHTML"的场景有意识管控,但该实践未推广为工具函数,其余大屏若有同类需求需人工复查。
综合源码约束,内网/离线部署的硬前提清单如下(部署操作本身见《06-部署运行》):
| 前提 | 依据 | 违反后果 |
|---|---|---|
| dist 全量托管(含约 1GB public 资产) | public/echarts-data、三大 three 目录被 fetch 按需引用 | GL/three 示例报"加载资源失败" |
| SPA fallback 到 index.html | src/router/index.ts createWebHistory | 刷新非根路径 404 |
| 无功能性外链出口(允许断网) | smoke EXTERNAL_HOST 断言 | 官方脚本取数失败、UMD 扩展库加载失败 |
| Google Fonts 可达或接受系统字体回退 | index.html 字体链接 | 仅视觉降级 |
| 服务器支持 .wasm 的正确 MIME | vendor-ammo/vendor-draco/vendor-basis、rhino3dm | three 物理与解码器示例失败 |
其中"允许断网"是本项目相对常见静态站最独特的部署诉求:多数站点的 CDN 依赖在内网是缺陷,这里是设计目标。smoke 的外链断言就是为此服务的回归防线——改依赖、改 alias、升级 three 生态包都可能重新引入 CDN import,跑一遍 pnpm test:smoke 即可暴露。
按"位置 → 现象 → 影响"逐条列出源码中可辨识的安全相关隐患(技术债视角的完整版见《13-技术债务与演进建议》):
src/chart-examples/loadScript.ts):任何拿到该引用的执行代码可注入任意 URL 脚本。当前调用方固定传本地路径,风险停留在"约定";若 optionCode 数据面未来开放(如管理端录入),此项立刻升级为注入点。建议在函数入口加 /^\// 或域名白名单断言;src/utils/sfcRuntime.ts):字面匹配不敌混淆与未收录 API(fetch 外带数据、history 操作、postMessage 等)。定位是行为护栏,建议在文档与错误信息中如实表述,避免使用者高估其保证;document.head.appendChild(script) 通道绕过依赖闸门:loadScript 与 sfcRuntime 是两个独立执行阶段,SFC 内部通过 ctx.loadScript(若未来透传)或直接 DOM API 注入脚本不受 __dynamicImport__ 闸门约束。现状无调用方触发,属"防线未合拢";index.html):唯一功能性边界外的网络依赖,内网部署需手动本地化(资产缺失,⚠️ 待确认是否有计划);new Function 全站 5 处(Renderer 两个求值函数、initCode、sfcRuntime executeRuntimeBody、antv 二分求值):CSP 下若部署环境禁用 unsafe-eval,整条兜底链路与沙箱链路全部失效。内网部署方案应确认 CSP 策略允许 eval,或在未来引入预编译产物替代运行时求值;总结:本项目的安全设计哲学是"内容受信 + 行为可见 + 外链清零"——不追求对抗恶意攻击者的隔离强度,而追求三类工程目标:迁移代码的历史残留不静默、失败用户看得见、内网部署不需要出网。所有防线(正则拦截、白名单闸门、smoke 外链断言、DOMPurify 清洗)都围绕这三个目标构建,评估或改造这套体系时应回到这三个目标,而不是套用通用 Web 安全清单。