云取 YunGet

插件安全模型

信任四级、校验链,以及这份机制不做(也不打算假装做了)的那些承诺。

docs/PLUGIN-SECURITY.md在 GitHub 上查看原文下载

这一页说的是云取的插件机制实际做了哪些防护、没有做哪些防护。写清楚"不做什么"比列一堆机制更重要 —— 一份让读者高估安全性的文档,比没有文档更危险。

先读 写一个云取插件 了解插件能做什么;这里只谈信任。


一句话概括#

插件是跑在应用进程里的第三方代码。我们给它设了内存、时间、能力和文件系统边界,但没有承诺能拦住一个有权限的插件做坏事。签名只回答"这份脚本有没有被改过、是不是某把钥匙发的",不回答"它安全不安全"。


能力级别:手机上只有 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 色系,标「未验证」+ 明确风险提示能

两条不肯让步的规则:

  1. 内置公钥只认官方源。 判定 official 要"源的身份 + 公钥来源"两个条件同时满足。不能因为某个第三方源用了同一把公钥就把它当官方 —— 那意味着任何人只要拿到官方签名过的插件就能自建一个"官方源"。
  2. 等级只影响显示与提示,不影响校验。 无论哪一级,安装都要求 sha256 + 签名齐备。低等级不是"可以少验一点",而是"验完了仍然要提醒你"。把这两件事混在一起(低等级就放宽校验)是这套设计最容易走错的一步。

另外,用户为该源选的等级优先于索引自称的等级:当索引里的 trustLevel 高于用户给的时候取用户的(保守方向)。所以一个源 fork 一份索引、把 trustLevel 改成 official 是没有用的。

自建源被添加时默认就是 community,不是"已验证"。


校验链#

从市场安装或更新一个插件,要依次过这几关。任何一关不过就不装,而且失败时不覆盖已装版本(先写新文件、成功后再切库里的记录)。

#检查不过会怎样
1索引条目齐备 downloadUrl + sha256 + signature + keyId拒绝 —— 缺校验材料的条目一律不装,不是"跳过校验继续装"
2minHostVersion ≤ 本机应用版本拒绝,提示需要更新的应用
3脚本体积:索引声明值 ≤ 512 KiB;下载后实际字节数也 ≤ 512 KiB,且等于 sizeBytes(声明为 0 时跳过比对)拒绝
4sha256(下载到的原始字节) == 索引里的 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,这种"看起来正常其实丢了信息"的清洗比不洗更难发现。
  • 别写混淆代码。 官方源不合并混淆过的脚本,这是可读性要求。
  • 网络请求要有失败回退。 解析失败不该表现为"点了没反应"。

相关的其它文档#

← 全部文档