《互联网产品的客户端架构·规模化与架构演进》3.2 沙箱、权限与失败隔离

插件沙箱的目标不是让插件跑不起来,而是让它在越权时被拒绝、在崩溃时被隔离。本节从一次插件读取全盘文件、拖垮首页的事故出发,给出权限、作用域与配额的三层模型,用可运行的 TypeScript 模块实现最小授权审计、失败隔离与熔断,并说明每个阈值为什么必须按插件配置。

一个插件拖垮了整个首页

某阅读器的插件市场允许第三方插件在首页插入卡片。一个「稍后读」插件为了统计阅读时长,请求了 read 权限,作用域写成 /,又在初始化时同步遍历用户目录。结果:首页启动从 400ms 涨到 6s,另外两个插件因为主线程被占满而超时,用户看到的是「插件全部加载失败」。

这个问题里没有一个环节是「插件写错了」这么简单:

  • 宿主把 read 当成布尔权限,授予后可以读任何路径。
  • 插件初始化没有超时,也没有配额。
  • 一个插件卡住,宿主没有隔离,其他插件一起失败。

沙箱要解决的不是「禁止插件」,而是三件事:越权可拒绝、失败可隔离、资源可计量。

权限不是布尔值

read: true 这种模型无法表达「只能读自己的数据目录」。可用的最小模型是三元组:

维度回答的问题例子
权限能做哪类操作read、write、network、storage
作用域能对哪些资源做file:/notes/*、net:*.example.com
配额能做多少次/多少量每次会话 10 次读

三者缺一不可。只有权限,插件可以读全盘;只有作用域,无法区分读写;没有配额,一个循环就能耗尽资源。

作用域匹配要明确规则,不能靠 startsWith 拍脑袋:

写法匹配不匹配
file:/notes/a.md精确路径file:/notes/b.md
file:/notes/*file:/notes/a.md、file:/notes/deep/b.mdfile:/secrets/a.md
net:*.example.comnet:api.example.comnet:example.com、net:example.com.evil.io
*任意资源(仅限宿主内部)不应授予第三方插件

最后一行不是笔误:通配一切的作用域应该只存在于宿主自己的代码里,不应该出现在插件授权表中。

最小授权要能算出来

「最小授权」常被当成一句口号,实际评审时没人知道某个权限该不该给。把它变成可计算的审计:

  • 收集插件实际发起的请求。
  • 请求的权限不在授权表里,记为 overreach(越权)。
  • 授权表里有、但整场会话没用到的权限,记为 unused(可裁剪)。

overreach 非空说明授权不足或插件行为异常,需要人工判断;unused 非空说明授权过宽,下一版可以收紧。两个清单都不为空时,优先处理 overreach,因为它可能已经是事故。

审计必须是旁路能力,不能在请求链路上做同步统计。否则审计本身会成为新的性能瓶颈,而且插件可以通过制造大量请求干扰统计。

失败隔离的三个层次

隔离不是一句 try/catch。至少要分三层:

层次手段拦住什么
单次调用try/catch + 超时插件抛错、卡死单次调用
插件级失败计数 + 熔断一个插件反复失败
宿主级降级 UI + 禁用入口插件整体不可用

只有第一层时,一个每帧都抛错的插件会刷满日志,仍然拖慢宿主。只有前两层时,插件被熔断后入口还在,用户会反复点击。三层都到位,故障才被限制在插件自己的边界内。

熔断阈值不能拍脑袋。可用的起点是「连续 2 次失败即熔断,冷却 5 分钟后半开」。阈值太低会误伤偶发失败,太高则失去隔离意义。阈值必须按插件类型配置,而不是全局一个数。

把沙箱写成代码

下面这个模块把授权判定、作用域匹配、配额扣减、失败隔离与最小授权审计放在一起。它不是完整的进程沙箱,而是宿主侧的第一道边界。

// file: src/plugin/plugin-sandbox.ts
// tier: L2
// verify: node --test test/plugin/plugin-sandbox.test.ts

export type Permission = "read" | "write" | "network" | "storage";

export type PermissionGrant = {
  permission: Permission;
  scopes: string[];
  quota: number;
};

export type PluginGrant = {
  pluginId: string;
  permissions: PermissionGrant[];
  failureThreshold: number;
};

export type SandboxRequest = {
  pluginId: string;
  permission: Permission;
  resource: string;
  units?: number;
};

export type DenyReason =
  | "plugin-unknown"
  | "permission-not-granted"
  | "resource-out-of-scope"
  | "quota-exceeded"
  | "circuit-open";

export type Decision =
  | { allowed: true; remaining: number }
  | { allowed: false; reason: DenyReason };

export type Job<T> = { pluginId: string; run: () => T };

export type JobResult<T> =
  | { pluginId: string; ok: true; value: T }
  | { pluginId: string; ok: false; error: string };

export type RunReport<T> = { results: Array<JobResult<T>>; failed: string[] };

export type PluginState = {
  failures: number;
  open: boolean;
  usage: Record<Permission, number>;
};

export type Sandbox = {
  authorize(request: SandboxRequest): Decision;
  runIsolated<T>(jobs: Array<Job<T>>): RunReport<T>;
  state(pluginId: string): PluginState | undefined;
};

export function matchScope(scope: string, resource: string): boolean {
  if (scope === "*") return true;
  const scopeParts = scope.split(":");
  const resourceParts = resource.split(":");
  if (scopeParts.length < 2 || resourceParts.length < 2) {
    return scope === resource;
  }
  if (scopeParts[0] !== resourceParts[0]) return false;
  const pattern = scopeParts.slice(1).join(":");
  const target = resourceParts.slice(1).join(":");
  if (pattern === target) return true;
  if (pattern.startsWith("*.")) {
    return (
      target.endsWith(pattern.slice(1)) &&
      target.length > pattern.length - 1
    );
  }
  if (pattern.endsWith("*")) {
    return target.startsWith(pattern.slice(0, -1));
  }
  return false;
}

function emptyUsage(): Record<Permission, number> {
  return { read: 0, write: 0, network: 0, storage: 0 };
}

export function createSandbox(grants: PluginGrant[]): Sandbox {
  const byPlugin = new Map<string, PluginGrant>();
  for (const grant of grants) byPlugin.set(grant.pluginId, grant);
  const states = new Map<string, PluginState>();

  function stateOf(pluginId: string): PluginState {
    let state = states.get(pluginId);
    if (!state) {
      state = { failures: 0, open: false, usage: emptyUsage() };
      states.set(pluginId, state);
    }
    return state;
  }

  function authorize(request: SandboxRequest): Decision {
    const grant = byPlugin.get(request.pluginId);
    if (!grant) return { allowed: false, reason: "plugin-unknown" };
    const state = stateOf(request.pluginId);
    if (state.open) return { allowed: false, reason: "circuit-open" };
    const permission = grant.permissions.find(
      (item) => item.permission === request.permission,
    );
    if (!permission) return { allowed: false, reason: "permission-not-granted" };
    const inScope = permission.scopes.some((scope) =>
      matchScope(scope, request.resource),
    );
    if (!inScope) return { allowed: false, reason: "resource-out-of-scope" };
    const units = request.units ?? 1;
    const used = state.usage[request.permission];
    if (units > permission.quota - used) {
      return { allowed: false, reason: "quota-exceeded" };
    }
    state.usage[request.permission] = used + units;
    return { allowed: true, remaining: permission.quota - used - units };
  }

  function runIsolated<T>(jobs: Array<Job<T>>): RunReport<T> {
    const results: Array<JobResult<T>> = [];
    const failed: string[] = [];
    for (const job of jobs) {
      try {
        results.push({ pluginId: job.pluginId, ok: true, value: job.run() });
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        results.push({ pluginId: job.pluginId, ok: false, error: message });
        failed.push(job.pluginId);
        const grant = byPlugin.get(job.pluginId);
        if (grant) {
          const state = stateOf(job.pluginId);
          state.failures += 1;
          if (state.failures >= grant.failureThreshold) state.open = true;
        }
      }
    }
    return { results, failed };
  }

  return {
    authorize,
    runIsolated,
    state: (pluginId: string) => states.get(pluginId),
  };
}

export type LeastPrivilegeReport = {
  unused: Permission[];
  overreach: SandboxRequest[];
};

export function auditLeastPrivilege(
  grant: PluginGrant,
  requests: SandboxRequest[],
): LeastPrivilegeReport {
  const used = new Set<Permission>();
  const overreach: SandboxRequest[] = [];
  for (const request of requests) {
    if (request.pluginId !== grant.pluginId) continue;
    const permission = grant.permissions.find(
      (item) => item.permission === request.permission,
    );
    const inScope =
      permission !== undefined &&
      permission.scopes.some((scope) => matchScope(scope, request.resource));
    if (!permission || !inScope) {
      overreach.push(request);
      continue;
    }
    used.add(request.permission);
  }
  const unused = grant.permissions
    .map((item) => item.permission)
    .filter((permission) => !used.has(permission));
  return { unused, overreach };
}

这段代码有三个关键点:

  1. authorize 的拒绝原因是分开的,调用方能区分「没授权」和「越界」,而不是统一报「失败」。
  2. 配额按权限分别计数,读配额耗尽不影响写配额。
  3. runIsolated 逐个执行任务,单个任务抛错只增加该插件的失败计数。

越权与配额的判据

一段沙箱实现是否合格,看四个判据:

  • 默认拒绝:没有显式授权的能力一律拒绝,不存在「未配置即放行」。
  • 作用域精确:匹配规则可测试,大小写、子域、路径穿越都有明确结果。
  • 配额独立:不同权限、不同插件的配额互不占用。
  • 失败有界:单个插件的失败次数与影响范围都有上限。

反例一:用 resource.startsWith(scope) 匹配。file:/notes 会匹配 file:/notes-secret,因为前缀相同。正确做法是比较到分隔符。

反例二:把配额做成全局计数。一个插件跑批量任务耗尽配额,其他插件全部被拒绝,这等于把隔离做成了连坐。

反例三:熔断后仍然保留入口。用户点击后没有任何反馈,会认为应用坏了,而不是插件被禁用。

熔断之后怎么办

熔断不是终点,而是状态机的一个中间态。完整的状态至少有三个:

状态行为进入条件离开条件
关闭正常放行初始状态连续失败达到阈值
打开一律拒绝失败达到阈值冷却时间结束
半开放行少量探针冷却结束探针成功则关闭,失败则重新打开

半开状态最容易被省略,但它决定了插件能否自愈。没有半开,熔断只能靠重启宿主恢复,这对桌面端是灾难。

探针数量也要有上限,通常 1 次就够。半开时放行全部请求,等于把熔断变成了一个延迟 5 分钟的开关。

一次权限申请的复盘

回到开头的阅读器。修复方案不是「不给 read 权限」,而是把权限拆细:

  • 插件只能读 file:/plugins/com.acme.readlater/*。
  • 首页卡片初始化有 200ms 超时,超时按失败计数。
  • 连续 2 次失败熔断,入口显示「插件暂时不可用」。

上线后同类事故从每月 3 次降到 0 次。关键不是阈值调得多准,而是每次失败都被记在某一个插件名下,不再扩散成全局故障。

小结

沙箱不是禁止插件,而是给插件划一条可执行的边界。

  • 权限模型必须是「权限 + 作用域 + 配额」三元组,布尔权限无法表达最小授权。
  • 作用域匹配规则要可测试,前缀匹配会把 notes 和 notes-secret 混为一谈。
  • 失败隔离分单次调用、插件级熔断、宿主级降级三层,缺一层故障就会外溢。
  • 熔断要有打开、半开、关闭三态,否则插件无法自愈。

边界越清晰,插件才越敢用。

阅读导航:上一节:3.1 插件协议与宿主边界 · 下一节:3.3 动态配置与热更新的停止条件 。

常见错误与边界

  1. 把 read 当成读自己的数据。 没有作用域的读权限就是全盘读,必须在授权表里写清路径。
  2. 用 startsWith 做作用域匹配。 目录前缀不是路径边界,/notes 与 /notes-secret 会被混在一起。
  3. 配额全局共享。 一个插件耗尽配额会连坐其他插件,配额至少要按插件、按权限分开。
  4. 只做 try/catch。 单次调用隔离挡不住反复失败,必须有插件级熔断。
  5. 熔断后不提示。 入口还在但点击无反应,用户会归因为宿主故障。
  6. 省略半开状态。 没有半开,插件只能等宿主重启才能恢复。
  7. 把 * 作用域发给第三方插件。 通配作用域只应留在宿主内部,不能进入授权表。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查