这一页说的是云取的插件机制实际做了哪些防护、没有做哪些防护。写清楚"不做什么"比列一堆机制更重要 —— 一份让读者高估安全性的文档,比没有文档更危险。
先读 写一个云取插件 了解插件能做什么;这里只谈信任。
一句话概括#
插件是跑在应用进程里的第三方代码。我们给它设了内存、时间、能力和文件系统边界,但没有承诺能拦住一个有权限的插件做坏事。签名只回答"这份脚本有没有被改过、是不是某把钥匙发的",不回答"它安全不安全"。
能力级别:手机上只有 L1#
除了"可不可信",插件还有能力级别这一维。它回答的是"这东西能碰到多大范围的能力"以及"改了要不要重启 App"。这两件事与信任等级无关:一个 L1 插件可能是官方签名的,一个 L2 插件也可能来自你信任的源。
| 级别 | 运行位置 | 能做什么 | 热加载 | 变更后 |
|---|---|---|---|---|
L1 · JS 插件(索引里 level: "js") | 应用内的 JS 沙箱,进程内 | 网盘登录/解析/取直链、网页与 API 调用、规则与轻逻辑 | 可以 | 立即生效 |
L2 · 原生插件(索引里 level: "native") | 宿主进程内,Kotlin/JVM 原生代码 | 接入其他下载器的插件生态(Adapter)、新的下载后端、文件 Handler(播放器)、系统级集成 | 不可以 | 必须重启 App |
L3(宿主内置)不是插件:只有下载调度、任务管理、权限裁决这类核心职责留在宿主里。
Android 端自 2.7.10 起暂时禁用 L2。 索引里写着 level: "native" 的条目会被拒绝安装,并给出"原生插件在手机端暂时停用,后续 Windows 版将启用"的明确说明。下面对 L2 的描述保留下来,是因为它讲的是这一档的机制与代价,Windows 端启用时要照它执行。
L2 装上、启停、卸载之后都需要重启 App 才生效,App 界面会在每次操作后明确提示。这是结构性的,不是实现偷懒:原生插件注册进来的类在进程生命周期内无法替换(类加载器换不掉已加载的类),它还可能持有播放器、硬件解码器、原生库句柄这类不可逆的系统资源,而且它跑在宿主进程内、没有沙箱。
L2 的能力与宿主等同。 它声明的能力只作展示,不是限制;装一个 L2 插件,等同于允许那段原生代码在你的设备上以应用的权限执行。我们把这一点写在安装确认里而不是藏起来,是因为写轻了(暗示有沙箱)是误导,写重了(说成"危险")会让这类插件没人敢用 —— 两者都是错的。判断依据仍然是签名与信任链,而不是这个级别本身。
索引里没写 level 字段的存量条目一律按 L1 读;写了当前应用不认识的值时,那条不会被安装,并会在插件市场页显示"未显示"的原因(通常是提示升级应用)。native 走的是同一条路,但理由单独说 —— 它不是"未知的新级别",而是"手机端已停用"。
信任四级#
每一级说的是"这个源/这个插件的身份可信到什么程度"。四级都能装,差别在界面上怎么显示、以及低等级时有没有醒目提示。
| 等级 | 什么情况下给 | 界面怎么显示 | 能装吗 |
|---|---|---|---|
official | 源的身份是官方,且签名用的公钥是随应用内置的那把 | 主色实心徽标,标「官方」 | 能 |
verified | 用户主动添加的源,条目带签名,公钥由该源自带(有签名,但发布者身份靠源自己声明) | 次色容器,标「已验证」 | 能 |
community | 用户主动添加的源,条目带签名但公钥来自网络 | 中性色,标「社区」 | 能 |
untrusted | 没有签名,或公钥拿不到(含从文件/粘贴导入的脚本) | error 色系,标「未验证」+ 明确风险提示 | 能 |
两条不肯让步的规则:
- 内置公钥只认官方源。 判定
official要"源的身份 + 公钥来源"两个条件同时满足。不能因为某个第三方源用了同一把公钥就把它当官方 —— 那意味着任何人只要拿到官方签名过的插件就能自建一个"官方源"。 - 等级只影响显示与提示,不影响校验。 无论哪一级,安装都要求
sha256+ 签名齐备。低等级不是"可以少验一点",而是"验完了仍然要提醒你"。把这两件事混在一起(低等级就放宽校验)是这套设计最容易走错的一步。
另外,用户为该源选的等级优先于索引自称的等级:当索引里的 trustLevel 高于用户给的时候取用户的(保守方向)。所以一个源 fork 一份索引、把 trustLevel 改成 official 是没有用的。
自建源被添加时默认就是 community,不是"已验证"。
校验链#
从市场安装或更新一个插件,要依次过这几关。任何一关不过就不装,而且失败时不覆盖已装版本(先写新文件、成功后再切库里的记录)。
| # | 检查 | 不过会怎样 |
|---|---|---|
| 1 | 索引条目齐备 downloadUrl + sha256 + signature + keyId | 拒绝 —— 缺校验材料的条目一律不装,不是"跳过校验继续装" |
| 2 | minHostVersion ≤ 本机应用版本 | 拒绝,提示需要更新的应用 |
| 3 | 脚本体积:索引声明值 ≤ 512 KiB;下载后实际字节数也 ≤ 512 KiB,且等于 sizeBytes(声明为 0 时跳过比对) | 拒绝 |
| 4 | sha256(下载到的原始字节) == 索引里的 sha256 | 拒绝 —— 内容被改过或索引过期 |
| 5 | 按 keyId 找公钥(内置 → 源的 publicKeyUrl),Ed25519 对原始字节验签 | 找不到公钥 → 拒绝;验签失败 → 拒绝 |
| 6 | 脚本自报的 defineMeta({ id, version }) 与索引条目逐字相等 | 拒绝 —— 索引在谎报版本 |
| 7 | 全部通过才落盘:先写新文件(文件名含 sha 前 8 位),成功后再切库里的记录 | 中途失败不覆盖已装版本 |
更新与安装共用同一条链路,没有任何一步被放宽。更新只是在这条链路之前多一个挑选步骤:semver 比较(索引最新版严格大于本地版本才认为有更新;相等不动;更小是降级,拒绝并提示,不静默降级)。这个比较发生在"决定要不要装"的时刻,不在安装校验里。
第 6 关为什么有分量:签名只覆盖脚本字节,而脚本自报的 version 就在这些字节里。有了这条比对,"索引把旧脚本标成新版本"和"索引把权限写成空、脚本却偷偷要权限"都会在安装前暴露。
签名载荷是脚本的原始字节:不剥 BOM、不做行尾归一化、不重新编码、不是"先算 sha256 再签摘要"。sha256 与 signature 覆盖同一串字节,所以第 4 关和第 5 关互相印证。细节见 打包与发布。
校验链的边界#
诚实地说清楚这几件事:
- 应用不下载也不校验
turbodl-plugin.json。 索引里的manifest快照与manifestUrl是展示/工具用的数据,不参与安装校验。 turbodl.apiMajor/requiredApiVersion的兼容性判断目前没有在 App 侧执行。 真正会拦下 ABI 不匹配的是脚本加载时引擎的握手(脚本plugin.requires({abiMajor})与加载器实现的主版本不符 → 加载失败)。所以"manifest 里填了正确的 ABI 版本"是给工具和将来用的,别把它当成一道已经生效的闸。- 公钥来自网络时,信任根就在那个网络上。 官方源的公钥随应用内置(离线也能验,改公钥的人不能伪造插件);自建源的公钥要从
publicKeyUrl取,也就是说能改那个地址的人就能换掉公钥、再换掉插件。这是自建源天然弱一档的原因。 - 公钥本身没有签名保护。 所以内置公钥要连指纹一起记(源仓库
keys/README.md里记了 SHA-256 指纹),换公钥时要核对。
权限模型#
实际授予 = 脚本声明 ∩ 加载器上限 ∩ 用户已确认- 脚本声明:
plugin.requires({permissions: [...]})。 - 加载器上限:宿主设定的授予集合。云取用的一档是
http, crypto, log, time。 - 用户已确认:市场安装时会在下载之前弹一次权限确认,把"要授予的能力"和信任等级摆在按钮前面 —— 确认之前不下载。
脚本不能在运行时自我提权。 没有任何 API 能改变已授予的集合。宿主 API 的每一次调用都过一遍能力检查:
- 能力没授予 → 抛
permission,消息点名要申请哪个能力; - 能力授予了但这次调用被拒(scheme 不合法、参数越界、超限)→ 各自的错误码。
两者分开是为了让插件作者能分辨"要加个 grant"和"要改这个调用"。
唯一刻意不抛错的是 log:一次日志调用不能弄坏一次下载。宿主撤掉 log 后,脚本的日志行被丢弃,宿主自己发一条诊断说明这件事。
声明了宿主没授予的能力 → 加载期就失败,而不是下载到一半才被拒。这是刻意的:失败发生在"尚无消费者可见"的时刻,只损失一次加载,不会留下一个已经被路由的半成品插件。
权限的实际含义#
| 能力 | 拿到它意味着插件能做什么 |
|---|---|
http | 向任意 http/https 地址发请求(含把用户给的链接、分享 ID 发出去)。Authorization/Cookie 等敏感头在跨 origin 时会被剥掉,凭据归属由 originUrl 声明 |
crypto | 算摘要、HMAC、CSPRNG 随机数、base64/hex 编解码 |
log | 写带自己 id 前缀的日志 |
time | 读宿主时钟、sleep |
storage | 在插件自己的沙箱里存 ≤ 4 MiB 的 KV |
env | 读宿主白名单里的环境变量(云取的白名单是空的,所以这一项实际拿不到任何东西) |
timer | 排宿主定时器(≤ 24 小时延迟) |
声明了 http 就意味着它能上网、能把你的链接发出去。 装之前请把权限清单看清楚 —— 这也是为什么市场安装前那一次确认弹窗不给跳过。
沙箱与资源边界#
| 边界 | 实际限制 |
|---|---|
| 文件系统 | 每个插件一个沙箱目录,只有 storage/ 和 downloads/ 两个子目录。文件名走单一收口:拒分隔符、拒 ..、拒绝对路径、拒点开头的名字,最后还复核 canonical path 仍在沙箱内(防符号链接/后缀把戏) |
| 解析器返回的路径 | 默认只接受裸文件名,落盘目录由宿主决定。含分隔符或绝对路径 → 拒绝 |
| 内存 | 每实例 32 MiB 堆上限,越限在 JS 里抛错(不是把宿主进程拖死) |
| 栈 | 512 KiB 原生栈上限:把无界递归变成可捕获的 InternalError,而不是硬崩 |
| CPU/时间 | 单次求值 5 秒(只计 JS 时间);一次调用总预算 20 秒(JS + 宿主调用,排队也计入)。两层都由独立线程的 watchdog 从外部中断 |
| 网络出口 | 只有 host.http,只有 http/https。传输政策(代理、DNS、TLS、UA)继承宿主配置,脚本只能收窄 |
| 数据面 | 脚本不在下载数据路径上:没有流式 API、没有 body 句柄、没有分块回调。字节面留在 Kotlin |
| 隔离 | 每个插件一个 QuickJS 实例(globals/heap/上限/中断互不可见),一个专属线程 |
| 卸载 | STOPPING → DRAINING → DISPOSED。手上的调用没跑完就拒绝关闭并计一次泄漏(泄漏一个引擎可以恢复,释放 JS 还在读的内存不行) |
这些都是资源与能力边界,不是"沙箱级隔离"。见下一节。
不做的承诺#
这一节是本文最重要的部分。
1. 没有对脚本行为的沙箱级隔离承诺#
JS 引擎给的是内存上限、栈上限、执行超时、能力边界这几条。它不是进程隔离,不是容器,不是 seccomp:
- 脚本和宿主在同一个进程里。它通过
host.*能做的事,就是它被允许做的事 —— 而"被允许"的集合里有http,那就是一个完整的出网通道。 - 一个声明了
http的插件可以把你的链接、你的分享 ID、你给它的任何参数发到任何地方。这不是漏洞,是它被授予的能力。 - 我们没有审计脚本在做什么,没有运行时的行为检测,没有"这个插件看起来像在偷数据"这类启发式。
- 内存/时间/能力的边界能防的是"一个坏掉的插件把应用拖垮",不是"一个恶意插件偷偷做坏事"。
如果你要装一个来路不明的插件,正确的判断依据是"我信不信写它的人",而不是"它看起来被沙箱包住了"。
2. 没有中心化审核#
官方源里的插件是人工看过的(可读性、权限与用法是否对得上、有没有混淆),但:
- 审核是有限的人工 review,不是形式化验证。看过的人也会漏。
- 第三方源完全没有审核 —— 用户自己加的那个源里有什么,只有那个源的作者知道。
community级别的源,插件照样会被验签,但"签名有效"只说明它没被改过,不说明写它的人想干什么。
3. "签名有效"只证明一件事#
签名有效 = 这份脚本的内容,与某个私钥的持有者发布的一致。
它不证明:
- 作者是好人;
- 脚本没有恶意行为;
- 脚本没有 bug;
- 脚本不会把你的数据发出去;
- 脚本的权限声明是"必要的"而不是"能拿就拿"。
同样地,官方源收录 ≠ 绝对安全。官方源的意义是"这个插件经过人工看过、来源明确、出问题能找到负责人",不是"你可以不看权限就装"。
4. 信任等级不是安全等级#
official / verified / community / untrusted 描述的是来源可追溯性,不是行为安全性。一个 official 插件如果有 bug,照样能弄坏你的下载;一个 untrusted 插件如果只做它声明的事,也不会伤害你。
5. 私钥泄露的后果是不可逆的#
Ed25519 没有"撤销签名"这回事:已经发出去的签名永远有效。能做的只有让别人不再信任那把钥匙(从索引里移除 keyId、必要时发布应用更新在内置吊销列表里拉黑它)。所以私钥泄露的处置是"尽快换钥匙并公告",而不是"撤回"。处置流程见源仓库的 keys/README.md。
6. 目前还没有的机制#
如实列一下现在没有的东西(有需要时再补,不是已经做好了没说):
- 没有源级别的插件黑名单/吊销列表下发机制(应用内置的公钥白名单是有的);
- 没有插件间的隔离承诺(每个插件一个 JS 实例,但它们同进程、同文件系统权限);
- 没有对插件网络请求的域名白名单(
http就是"能访问任何 http/https 地址"); - 没有对
downloads/之外写文件的任何允许机制(allowExternalDestination云取没有打开,这是好事); - 没有"已装插件的行为审计"—— 插件页里那个「已注册」列表来自引擎的诊断快照(进程内实时状态),不落盘、不跨启动保留。想知道某个插件过去做过什么,只能翻日志。
发现恶意插件怎么办#
开一个 Issue,说明是哪个插件(id + 版本 + 来源)、你观察到了什么、有日志的话附上。
维护者会做的事:
- 核实之后从索引移除该插件(用户不会再有更新);
- 如果是签名密钥被滥用或泄露,吊销该 keyId:从
source.keyIds里移除、用新密钥重签仍然有效的版本、在所有渠道公告"哪个 keyId 作废、从哪一刻起、换到哪个 keyId"; - 严重情况下发布应用更新,把该 keyId 加进内置的吊销列表 —— 这是唯一能挡住"还没更新的老版本用户"的办法;
- 作废的公钥文件保留在仓库里但改名标注(如
official-2026.revoked.pub),并记录泄露经过与影响范围。删掉它只会让历史审计变成一团雾。
已经装了的用户不受索引移除的影响(本地还有脚本),所以如果你自己装了可疑插件,在插件页里卸载它 —— 卸载会走完整的停用流程并清掉它的沙箱 downloads/。
想从 community 升到 verified(让官方核对你的身份与签名密钥)请开 issue 并附上你的公钥指纹。
给插件作者的几条#
- 只声明真正用到的能力。 用户装之前会看到这份清单,而且"声明了权限却看不出用在哪"是 PR 会被拒的理由之一。
- 不要把密钥写进脚本。 脚本是明文、会被下载、会被签名覆盖 —— 里面的一切都是公开的。要存 token 就用
host.storage(它是本地状态,不进仓库)。 - 洗不可信输入。 文件名、链接参数都来自别人。丢掉路径分隔符、控制字符、首尾点/空格与
..,但别用"只保留[A-Za-z0-9._-]"那种白名单 —— 它会把片子.mp4洗成mp4,这种"看起来正常其实丢了信息"的清洗比不洗更难发现。 - 别写混淆代码。 官方源不合并混淆过的脚本,这是可读性要求。
- 网络请求要有失败回退。 解析失败不该表现为"点了没反应"。