# 插件安全模型 这一页说的是云取的插件机制**实际**做了哪些防护、**没有**做哪些防护。写清楚"不做什么"比列一堆机制更重要 —— 一份让读者高估安全性的文档,比没有文档更危险。 先读 [写一个云取插件](PLUGIN-DEV.md) 了解插件能做什么;这里只谈信任。 --- ## 一句话概括 插件是**跑在应用进程里的第三方代码**。我们给它设了内存、时间、能力和文件系统边界,但**没有**承诺能拦住一个有权限的插件做坏事。签名只回答"这份脚本有没有被改过、是不是某把钥匙发的",**不回答"它安全不安全"**。 --- ## 能力级别:手机上只有 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` | 拒绝 —— 缺校验材料的条目一律不装,不是"跳过校验继续装" | | 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 关互相印证。细节见 [打包与发布](PLUGIN-PUBLISH.md)。 ### 校验链的边界 诚实地说清楚这几件事: - **应用不下载也不校验 `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`](https://github.com/jiayuxuan123/YunGet-Plugins/blob/main/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`,这种「看起来正常其实丢了信息」的清洗比不洗更难发现。 - **别写混淆代码。** 官方源不合并混淆过的脚本,这是可读性要求。 - **网络请求要有失败回退。** 解析失败不该表现为"点了没反应"。 --- ## 相关的其它文档 - [写一个云取插件](PLUGIN-DEV.md) —— 插件能做什么、怎么写第一个 - [插件 ABI 参考](PLUGIN-API.md) —— 能力清单、错误码、上限表 - [打包与发布](PLUGIN-PUBLISH.md) —— 签名载荷、索引格式、自建源 - [隐私说明](PRIVACY.md) —— 应用本身收集与不收集什么