一个插件拖垮了整个首页
某阅读器的插件市场允许第三方插件在首页插入卡片。一个「稍后读」插件为了统计阅读时长,请求了 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.md | file:/secrets/a.md |
net:*.example.com | net:api.example.com | net: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 };
}
这段代码有三个关键点:
authorize的拒绝原因是分开的,调用方能区分「没授权」和「越界」,而不是统一报「失败」。- 配额按权限分别计数,读配额耗尽不影响写配额。
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 动态配置与热更新的停止条件 。
常见错误与边界
- 把
read当成读自己的数据。 没有作用域的读权限就是全盘读,必须在授权表里写清路径。 - 用
startsWith做作用域匹配。 目录前缀不是路径边界,/notes与/notes-secret会被混在一起。 - 配额全局共享。 一个插件耗尽配额会连坐其他插件,配额至少要按插件、按权限分开。
- 只做
try/catch。 单次调用隔离挡不住反复失败,必须有插件级熔断。 - 熔断后不提示。 入口还在但点击无反应,用户会归因为宿主故障。
- 省略半开状态。 没有半开,插件只能等宿主重启才能恢复。
- 把
*作用域发给第三方插件。 通配作用域只应留在宿主内部,不能进入授权表。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。