ChipCode 插件清单校验:一个 .. 能走多远
插件系统的安全边界,几乎全部集中在一个文件上:plugin.json。
ChipCode 的插件能跑命令行、能开串口、能读写项目目录。这些能力是它存在的理由,也是它最危险的地方。所以我们在安装那一刻做了一次尽量严格的校验——不是因为它优雅,是因为漏一条就是一个洞。
这篇讲 validateManifest 到底在校验什么,以及每条规则背后对应哪种攻击。
插件能声明什么
一个最小清单长这样:
{
"id": "my-toolchain",
"name": "My Toolchain",
"version": "1.0.0",
"capabilities": {
"compile": {
"transport": "cli", "command": "make", "args": ["-j4"] }
}
}
id 是唯一标识,capabilities 是能力表。宿主只认这张表里的 key——比如 compile、flash、monitor。插件说自己能编译,宿主才会在“编译”这个动作发生时把请求路由给它。
再复杂一点,插件可以声明项目模板:
"projectTypes": [{
"id": "my-firmware",
"scaffold": [{
"file": "src/main.cpp", "content": "// {name}" }]
}]
{name} 会被替换成用户新建项目时输入的名字。
问题就出在这行替换上。
第一条:{name} 是个注入点
如果用户输入的项目名是 ../../../../Users/xxx/AppData/Roaming/Microsoft/Windows/Start Menu/Programs/startup,而插件不做校验,它就能往任意位置写文件。开机自启目录是个经典目标。
我们在两处拦:
第一处,用户输入的项目名本身做白名单过滤——只允许字母数字、下划线、连字符、中文,长度限死。
第二处更关键:scaffold 里的 file 路径必须是一段纯相对路径,解析后不能跳出插件声明的项目根目录。
实现上不是字符串匹配 ..,那是挡不住的(..%2f、....//、Windows 下的 ..\\、还有各种大小写混写)。正确做法是把路径规范化之后再做前缀判断:
const root = path.resolve(projectRoot);
const target = path.resolve(root, entry.file);
if (target !== root && !target.startsWith(root + path.sep)) {
throw new ManifestError(`scaffold path escapes project root: ${
entry.file}`);
}
注意这里必须用 resolve 而不是 join,而且比较时要带上路径分隔符——否则 /app/project-evil 会被 /app/project 的 startsWith 放过。
绝对路径(C:\\Windows\\...、/etc/...)在第一步就会被 resolve 拼接规则处理掉,但为了给出清晰的错误信息,我们还是单独检查了一次 path.isAbsolute。
第二条:transport 必须是枚举成员
transport 决定宿主用哪个驱动去执行。目前是七种:cli、http、grpc、mcp、mqtt、internal、backend。
为什么要卡死枚举?因为这个字段会被用来查一张驱动表。如果允许任意字符串,最坏的情况是查表失败后走到某个默认分支——而默认分支往往是“当作 cli 直接执行”。
类型系统在这里帮不上忙,因为清单是外部 JSON。所以校验是运行时的:
const TRANSPORTS = new Set(["cli","http","grpc","mcp","mqtt","internal","backend"]);
if (!TRANSPORTS.has(cap.transport)) {
throw new ManifestError(`unknown transport: ${
cap.transport}`);
}
同理,capabilities 的 key 也走一张白名单。不是我们不愿意让插件自定义能力名,是因为能力名同时决定了 UI 上哪个按钮可用、哪个阶段会被推进——开放自定义等于把状态机的入口交出去。
第三条:command 的 placement
cli 通道要声明命令怎么跑:在工作目录里、在某个子目录里、还是用绝对路径。
placement 只有三个值:cwd、plugin、absolute。
cwd:在用户项目目录里执行,最常用plugin:在插件自己的安装目录里执行,用于插件自带的小工具absolute:必须提供一个绝对路径,且该路径要在安装时由用户确认过
没有 path 这一项。我们拒绝了“相对路径 + 自由拼接”这种设计,因为它把命令解析交给了字符串拼接,而字符串拼接是漏洞的温床。
第四条:webview 的 entry
有些插件带自己的面板,用 views.webview 声明入口:
"views": {
"webview": {
"entry": "ui/index.html" } }
这个 entry 必须满足两个条件:相对路径、且解析后仍在插件目录内。加载时走自定义协议,协议处理器只服务当前已启用的插件,并且带 MIME 白名单——.html、.js、.css、.png、.svg 这些,其余一律 404。
为什么要 MIME 白名单?因为如果协议能返回任意类型,一个被篡改的插件就能返回 text/html 之外的东西诱导渲染进程做奇怪的事。我们的 webview 跑在沙箱里,但没有理由给它留口子。
校验发生在安装时,不是运行时
这一点是刻意的。
校验放在加载时做,好处是每次启动都检查一遍,坏处是启动变慢,而且——更重要的是——错误发现的时机太晚。
用户在插件市场点“安装”,如果清单有问题,应该在这一刻就告诉他“这个插件不合规”,而不是等他装完、开了项目、点了编译,才在某个日志深处报一句 invalid manifest。
所以安装流程是:拉清单 → validateManifest → 通过则写入本地 registry → 失败则整个安装中止,磁盘上不留任何东西。
顺带说一个实践中的细节:我们把校验函数的返回设计成收集所有错误,而不是遇到第一个就抛。因为插件作者改清单的时候,一次看到五个问题比改五次看到五个问题体验好得多。
这些规则挡不住什么
要说清楚边界。
validateManifest 只能保证清单声明的意图是合法的。一个 transport: cli 的插件,声明 command: make,它在运行时完全可以在 Makefile 里干别的事。清单校验拦不住这个。
真正拦住它的是另一层:官方插件进市场前有人审代码,第三方插件不给“官方”这层背书,用户自己判断来源。
清单校验解决的是“无意之失”和“低成本攻击”,不是“恶意代码”。后者靠的是代码审查和权限边界,不是几个 if。
知道这条边界在哪,比把校验写得多严更重要——因为把清单校验当成安全兜底,才是真正危险的地方。