卷 IV · 墙CH 17深度 17/24

权限:把 Chrome 的模型拆下来装上

权限系统的技术部分,是每个能力函数第一行的一句 if难的全在别处:粒度切多细、名字怎么起用户才看得懂、什么时候问、以及那个所有权限系统都逃不掉的困境 —— 用户会点「同意」,因为他想用那个功能。这一章拆开 Chrome 的模型,然后给我们的宿主装上一个真会拒绝的闸门。

权限闸门粒度activeTab同意疲劳

先看它工作

下面这个 demo 里,插件的代码固定不变,它会依次尝试 5 个操作。你来决定它的清单里声明了哪些权限 —— 闸门是真的,拒绝是真的抛出的 PermissionDenied

默认状态(只声明 notes.readui)下,5 个操作里放行 2 个、拒绝 3 个。把 fs 勾上,那个读 ~/.ssh/id_rsa 的调用立刻就通了 —— 这就是权限的全部:它不改变插件能写什么代码,只改变哪些调用会成功。

技术部分:一句 if

闸门装在宿主交给插件的那个 ctx 对象上,每个能力函数的第一行:

Host.prototype.makeApi = function (rec) {
  const declared = rec.manifest.permissions || [];

  function need(perm) {
    if (!host.strict) return;
    if (!declared.includes(perm)) {
      host.log(`[宿主] ✗ 拒绝:${rec.manifest.id} 调用需要 "${perm}",但清单里没声明`);
      throw new PermError(rec.manifest.id, perm);
    }
  }

  return {
    notes: {
      list:  () => { need('notes.read');  return host.notes.map(shallow); },
      read:  (id) => { need('notes.read');  return host.find(id)?.body ?? null; },
      write: (id, t) => { need('notes.write'); /* … */ },
    },
    ui: {
      addCommand: (...a) => { need('ui'); /* … */ },
      status:     (t) => { need('ui'); /* … */ },
    },
    net: { fetch: (url) => { need('net'); /* … */ } },
    fs:  { read:  (p) =>   { need('fs');  /* … */ } },
  };
};

就这么多。技术上真的没有别的了。

但有三个设计点值得说清楚:

① 每个插件必须有自己的 ctx

注意 makeApi(rec)按插件调用的 —— 每个插件拿到一个独立的 ctx,闭包里捕获着它自己的 declared 数组。

如果所有插件共享一个 ctx,权限系统就根本无从谈起(你没法知道当前是谁在调)。这就是第 6 章说「activate(ctx) 让宿主能为每个插件单独定制能力对象」时埋的伏笔。

② 拒绝要抛异常,不要静默返回

// ✗ 静默失败:插件作者会以为「这个笔记是空的」,查一整天
read: (id) => declared.includes('notes.read') ? realRead(id) : null,

// ✓ 抛错:一秒钟就知道是权限问题
read: (id) => { need('notes.read'); return realRead(id); },

而且异常要带足信息:哪个插件、缺哪个权限、该怎么修插件「helper-pro」没有声明 "fs" 权限Permission denied 有用一百倍。

③ 用 Proxy 兜住「没想到的属性」

上面的写法只保护了你列出来的方法。如果 ctx 上还有别的属性(比如你临时加了个调试用的字段),就漏了。用 Proxy 做个兜底(第 16 章那段代码),让白名单之外的一切访问都抛错。

◆ 但请记住第 14 章的结论

这套闸门能可靠地防住:粗心的插件作者、不知情的越界(比如插件用的某个第三方库偷偷读了文件)、以及插件依赖你的内部实现。

防不住:蓄意的恶意代码 —— 那种代码根本不用你的 ctx,它三行就能拿到 globalThis,然后直接 fetch

权限系统要配合运行时隔离才有安全意义(第 16 章)。单独存在时,它是一个优秀的工程工具(让越界显式化、让 API 使用可审计),而不是一个安全边界。

Chrome 之所以敢让任何人上传扩展,是因为它同时有权限模型独立世界 + 进程沙箱。缺任何一半都不行。

难的部分:粒度

技术做完了,现在是真正的工作。把「能力」切成几份、怎么切?

切得太粗,权限就没有意义:

{ "permissions": ["all"] }        // 等于没有权限系统

切得太细,没人受得了:

{ "permissions": [
  "notes.read.title", "notes.read.body", "notes.read.metadata",
  "notes.write.body", "notes.write.title", "notes.list.recent",
  "ui.command.add", "ui.command.remove", "ui.status.write", ...
]}                                 // 插件作者会崩溃,用户会直接点同意
◆ 判断粒度的唯一标准

能不能用一句用户看得懂的话说清楚,而且用户看了会真的犹豫一下。

拿这个标准去试:

notes.read → 「读取你的所有笔记」→ ✓ 清楚,而且会让人想一下。
fs → 「读取你电脑上的任意文件」→ ✓ 非常清楚,会让人警惕。
ui → 「在界面上添加命令和状态」→ ✓ 清楚,而且用户不会在意(这也是有用的信息 —— 说明它可以默认给)。
notes.read.title → 「读取你笔记的标题但不含正文」→ ✗ 用户不会区分这个差别,白增加复杂度。

如果一条权限你翻译不成一句人话,说明粒度切错了。如果翻译出来用户根本不会犹豫,那它就不该是一条需要授权的权限。

一个务实的起点:按「能力域 + 读写」切。也就是本书宿主用的那套 —— notes.read / notes.write / ui / net / fs。五条,每条都能说清楚,读和写分开(因为「读我的笔记」和「改我的笔记」在用户心里完全是两件事)。

Chrome 的三个聪明设计

Chrome 的权限模型是这个领域最成熟的,有三点值得直接抄。

① 把「什么时候要」分成两档

{
  "permissions": ["storage", "activeTab"],          // 安装时就给,不弹窗(因为无害)
  "host_permissions": ["https://example.com/*"],    // 安装时弹窗警告
  "optional_permissions": ["topSites"],             // 用到那天再弹窗
  "optional_host_permissions": ["https://*/*"]      // 用到那天再弹窗
}

为什么这个分档重要?因为「安装时一次性要一堆权限」有个致命问题:用户此时还没用过这个插件,无法判断这些权限是否合理,于是他只能全部同意或者放弃安装。

optional_permissions 让插件可以说:「基础功能不用额外权限;等你要用『同步到云端』那个功能时,我再问你要网络权限。」这时候用户手里有上下文了,判断质量高得多。

activeTab:把用户意图本身当成授权

这是我认为整个权限设计领域最漂亮的一手。

一个扩展想读当前页面的内容,传统做法是申请 host_permissions: ["<all_urls>"] —— 「读取你在所有网站上的所有数据」,一个吓人的权限,而且它是永久的、后台随时可用的。

activeTab 换了个思路:不预先给任何网站权限。但当用户主动点击你的扩展图标时,临时授予当前这一个标签页的访问权。

<all_urls>activeTab
范围所有网站用户刚点击的那一个标签页
时间永久,后台随时本次交互期间
安装时的警告很吓人没有警告
用户的心智「它一直在看我所有网页」「我让它看了这一页」

洞察在于:用户点击图标这个动作,本身就表达了「我要你在这个页面上干活」的意图。既然意图已经明确表达,就不需要再单独问一次 —— 而且这个授权天然是最小范围、最短时长的。

这个思路可以推广:你的系统里有没有某个用户动作,本身就等价于一次授权?用户把一个文件拖进来 = 授权读这个文件;用户选中一段文字再点插件 = 授权访问这段文字。这类「隐含授权」比弹窗好一个数量级,因为它零打扰、范围精确、而且用户的心智模型是对的。

③ 声明式规则:连数据都不给它看

MV3 里最有争议、但思想最值得学的一条:网络请求拦截从「你写代码逐个判断」改成了「你提前声明规则,浏览器执行」declarativeNetRequest)。

// 扩展声明规则,而不是拿到每个请求去判断
{
  "id": 1,
  "priority": 1,
  "action": { "type": "block" },
  "condition": { "urlFilter": "||ads.example.com", "resourceTypes": ["script"] }
}

差别是根本性的:旧模型里扩展能看到你访问的每一个 URL(哪怕它只想拦广告);新模型里扩展看不到任何请求,它只是提前告诉浏览器该拦什么。

这就是「声明式优于命令式」在安全上的价值:如果一个功能能用「声明规则」表达,插件就不需要拿到数据本身。

(争议在于:声明式规则的表达能力弱于任意代码,一些复杂的拦截逻辑做不了。这是真实的损失,社区争论了很多年。但那个权衡的形状 —— 用表达力换取「不需要交出数据」—— 值得你在自己的系统里认真考虑。

所有权限系统都逃不掉的困境

必须诚实地讲这一节。

⚠ 用户会点「同意」

无论你的权限提示写得多清楚,绝大多数用户在看到权限弹窗时,心里想的是「我要用这个功能」,而不是「我在评估风险」。

这有大量研究支持,也符合常识:用户是为了某个目的才来装这个插件的,权限弹窗只是挡在目的前面的一道门。他会点同意,然后立刻忘记自己同意了什么。

所以权限系统的真实价值,不在「让用户做出明智决定」(那基本是幻想),而在这四件事:

① 让恶意行为需要显式申请。一个申请了「读取所有文件」的记事本插件,是可疑的 —— 哪怕用户点了同意,审核者、安全研究者、以及后来的人都能看到这个不匹配。它把隐蔽的攻击变成了公开的可疑。

② 给审核提供抓手。商店可以自动标记「权限申请与描述不符」的插件。这是可规模化的。

③ 限制误伤范围。一个被入侵的插件(供应链攻击,第 22 章),能造成的破坏被它当初申请的权限限制住了。这一条极其重要,因为它防的是「好人的插件被坏人接管」—— 而这比「坏人写恶意插件」常见得多。

④ 让用户事后能查。出事之后,「哪些插件有网络权限」是一个能回答的问题。

把目标定在这四条上,你的权限系统会设计得好得多 —— 比如你会更重视「权限列表要能被机器读取和比对」,而不是纠结弹窗文案怎么写才能让用户认真看。

装到自己身上

◆ Kotlin:用密封类型让权限无法被绕过

JS 里权限检查是运行时的字符串比较。Kotlin 里可以做得更好 —— 让「没检查权限就调用」变成编译错误

enum class Permission(val id: String, val userFacing: String) {
    NOTES_READ ("notes.read",  "读取你的所有笔记"),
    NOTES_WRITE("notes.write", "修改你的笔记"),
    UI         ("ui",          "在界面上添加命令"),
    NET        ("net",         "访问网络"),
    FS         ("fs",          "读取你电脑上的任意文件"),   // ← 危险
}

class PermissionDeniedException(val pluginId: String, val perm: Permission) :
    SecurityException("插件「$pluginId」没有声明 \"${perm.id}\" 权限")

class PluginContext(
    private val pluginId: String,
    private val granted: Set<Permission>,
    private val host: Host,
) {
    // 所有能力都必须经过这个闸
    private inline fun <T> requiring(perm: Permission, block: () -> T): T {
        if (perm !in granted) {
            host.audit(pluginId, perm, allowed = false)
            throw PermissionDeniedException(pluginId, perm)
        }
        host.audit(pluginId, perm, allowed = true)
        return block()
    }

    val notes = object : NotesApi {
        override fun read(id: String) = requiring(Permission.NOTES_READ) { host.find(id)?.body }
        override fun write(id: String, text: String) = requiring(Permission.NOTES_WRITE) { host.write(id, text) }
    }
    val fs = object : FsApi {
        override fun read(path: String) = requiring(Permission.FS) { File(path).readText() }
    }
}

三个白得的好处:

userFacing 和权限定义在一起,不可能出现「加了新权限但忘了写提示文案」。而且你可以写个测试断言每个权限都有非空文案。

audit() 是免费的。既然所有能力都过这个闸,那「这个插件到底用了哪些 API、用了多少次」就自动有了 —— 这是你设计 API 演进、以及排查问题时最有价值的数据。

enum 让「未知权限」在解析清单时就能被发现(第 7 章那条「未知即错误」的原则,用类型系统实现)。

◆ 一个今天就能做的检查

把你的 ctx(或者任何你给插件的 API 对象)打印出来,对每个方法问一句:

「如果一个恶意插件调用这个方法,它能做什么?」

然后把答案按危害排序。排在最前面的那三个,就是你最该先加权限的地方 —— 通常是:读用户数据、写用户数据、访问网络。

你多半会发现有一两个方法的危害远超你的预期(「等等,这个方法能拿到完整路径?」)—— 那个发现本身就值回这五分钟。

这一章的一句话

权限的技术部分是一句 if,难的是粒度(能不能翻译成一句让用户犹豫的人话)和时机(能不能等到用户有上下文时再问);而它真正的价值不是让用户做明智决定,是让恶意行为必须公开申请、让被入侵的插件破坏有限。

下一章是卷 IV 的收尾,也是第三次动手:我们把宿主改成跨边界的,亲手搭一座 RPC 桥 —— 然后你就会彻底明白,为什么 VS Code 不给插件 DOM。