能力与安全

本页内容

沙箱插件默认是隔离的。除了读写自己的 KV 与 storage 之外,插件若要做任何事,都必须在其 manifest 中声明一项 capability。沙箱 bridge 会根据这些声明,对主机提供的每一项 API 进行门控——未声明 content:read 的插件不会获得 ctx.content,未声明 network:request 的插件不会获得 ctx.http。

本页说明每项 capability 授予什么、沙箱如何强制执行它们,以及哪些内容不在强制范围内。

声明 capabilities

Capabilities 写在 emdash-plugin.jsonc 中,与 slug 及其余信任契约一起:

{
	"slug": "plugin-hello",
	// ...identity + profile...

	"capabilities": ["content:read", "network:request"],
	"allowedHosts": ["api.example.com"]
}

只声明插件真正需要的内容。注册表会在安装前向站点运营者展示这些 capabilities,因此每多声明一项,都会要求他们批准插件并未使用的访问权限。

Capability 参考

Capability授予的访问权限
content:readctx.content.get()、ctx.content.list()、ctx.content.getTranslations()、ctx.content.getPublicUrl()
content:revisions:readctx.content.listRevisions()、ctx.content.getRevision()(隐含 content:read)
content:writectx.content.create()、ctx.content.update()、ctx.content.delete()(隐含 content:read)
content:publish版本化的发布、取消发布、排期与取消排期操作(隐含 content:read)
content:restore读取并恢复回收站中的内容
comments:readctx.comments.get()、ctx.comments.list()、ctx.comments.count() 以及评论的个人数据
comments:moderate带期望状态并发控制的 ctx.comments.setStatus()(隐含 comments:read)
schema:readctx.schema.listCollections()、ctx.schema.getCollection()
hooks.content-policy:registercontent:beforePublish、content:beforeSchedule 与 content:beforeUnpublish 策略钩子
taxonomies:readctx.taxonomies.getAll()、ctx.taxonomies.getTerms()、ctx.taxonomies.getEntryTerms()
taxonomies:writectx.taxonomies.createTerm()、ctx.taxonomies.addEntryTerms()、ctx.taxonomies.removeEntryTerms()(隐含 taxonomies:read)
redirects:readctx.redirects.list()、ctx.redirects.get()
redirects:writectx.redirects.create()、ctx.redirects.update()、ctx.redirects.delete()(隐含 redirects:read)
media:readctx.media.get()、ctx.media.list()
media:bytes:read对就绪媒体的 ctx.media.readBytes(),带缓冲上限的响应
media:metadata:write用于替代文本、说明文字与焦点的 ctx.media.updateMetadata()
media:writectx.media.getUploadUrl()、ctx.media.upload()、ctx.media.delete()(隐含 media:read)
network:requestctx.http.fetch() — 受 allowedHosts 限制
network:request:unrestricted无主机限制的 ctx.http.fetch()(仅用于用户配置的 URL)
users:readctx.users.get()、ctx.users.getByEmail()、ctx.users.list()
email:sendctx.email.send()(需要已配置的邮件提供商插件)
hooks.email-transport:register允许注册独占的 email:deliver 钩子(传输提供商)
hooks.email-events:register允许注册 email:beforeSend / email:afterSend 钩子
hooks.page-fragments:register允许注册 page:fragments 钩子(仅限原生插件)

以下规则会影响插件需要哪些 capabilities:

  • 隐含关系。 content:write、content:revisions:read 与 content:publish 会自动隐含 content:read;comments:moderate 隐含 comments:read;taxonomies:write 隐含 taxonomies:read;media:write 隐含 media:read;redirects:write 隐含 redirects:read;network:request:unrestricted 隐含 network:request。你无需两者都列出。
  • 媒体权限是分开的。 media:read、media:bytes:read 与 media:metadata:write 互不隐含。插件用到的每一项操作都要单独声明。现有的 media:write capability 为兼容性仍隐含 media:read。
  • 分类法与内容是分开的。 分类法 capabilities 不会授予 content:read 或 content:write。若插件还要读取或编辑条目字段,请声明相应的内容 capability。
  • 发布策略与内容访问是分开的。 hooks.content-policy:register 允许插件通过策略钩子事件检查并拒绝发布状态变更。它不提供 ctx.content,也不授予内容编辑或发布操作。
  • network:request:unrestricted 用于用户配置的 URL。 运营者自行输入目标 URL 的 webhook 类插件,需要访问清单中未列出的主机。始终调用已知 API 的插件应使用 network:request + allowedHosts。
  • email:send 受配置门控,而不仅仅是 capability。 插件可以声明 email:send,但只有当某个其他插件已注册 email:deliver 传输时,ctx.email 才会被填充。

content:read 返回安全的条目身份信息,包括作者 ID、翻译组、修订指针与行版本。使用 getTranslations() 发现同级语言版本,使用 getPublicUrl() 按站点的语言与尾部斜杠规则解析已发布路由。对于草稿、不可路由的集合、缺失的 slug,以及站点不提供服务的语言,getPublicUrl() 返回 null。它从不返回预览 URL。

修订快照可能包含管理员后来删除的字段值。仅在插件需要保留的历史时声明 content:revisions:read。修订结果会省略修订作者身份。

schema:read 暴露集合与字段定义,不含数据库 ID、时间戳、迁移元数据或 SQL 列类型。隐藏集合仍然可见,因为 hidden 控制的是管理导航,而非数据访问。

创建与翻译内容

ctx.content.create() 接受可选的第三个参数,用于指定新条目的语言:

const post = await ctx.content.create(
	"posts",
	{ title: "繁體中文" },
	{ locale: "zh-tw" },
);

语言匹配不区分大小写,并会存储站点语言配置中的大小写形式,因此当配置为 zh-TW 时,zh-tw 会变为 zh-TW。格式错误的显式语言总是会抛错;当已配置 i18n 时,不在已配置列表中的显式语言也会抛错。省略该选项时,EmDash 使用站点已配置的默认语言;未配置 i18n 的站点保持 en 默认值。

要为已有条目添加一种语言,将其数据库 ID 作为 translationOf 传入:

const translatedPost = await ctx.content.create(
	"posts",
	{ title: "Bienvenue", sku: "ignored-for-shared-fields" },
	{ locale: "fr", translationOf: sourcePost.id },
);

源条目必须是同一集合中的活动条目。新条目加入其翻译组,继承其署名积分与分类法分配,并以源条目的值作为标记为不可翻译字段的起始值。为不可翻译字段提供的值在创建翻译时不会覆盖源值。内容验证与保存钩子走与其他内容创建相同的运行时路径。EmDash 不会重入创建方自己的 content:afterSave 钩子,从保存钩子内部创建的内容也不会再次运行保存钩子。

每个翻译组每个语言只能有一个活动条目。为同一组与语言创建第二个条目会抛出 CONFLICT 错误。缺失的源抛出 NOT_FOUND,无效或未配置的语言抛出 VALIDATION_ERROR,保存钩子可用 SAVE_REJECTED 阻止创建。

更改发布状态

声明 content:publish 以发布、取消发布、排期或取消排期条目。每项操作都需要 getVersioned() 或上一次操作返回的不透明 _rev。EmDash 将这些方法路由到与 REST 和 MCP 操作相同的策略钩子、修订提升、语言同步、重定向、媒体使用更新、缓存失效与 after-hooks。

以下路由仅在自读取以来条目未更改时发布当前草稿:

const current = await ctx.content!.getVersioned!("posts", postId);
if (!current) return { ok: false, error: "NOT_FOUND" };

try {
	const published = await ctx.content!.publish!("posts", postId, {
		_rev: current._rev,
	});
	return { ok: true, content: published.item, _rev: published._rev };
} catch (error) {
	return { ok: false, error: "PUBLISH_FAILED" };
}

schedule() 接受 { scheduledAt, _rev };其他发布方法接受 { _rev }。这些方法不接受 publishedAt 覆盖。

单独声明 content:restore 以读取并恢复回收站中的条目。对于活动或缺失的条目,getTrashedVersioned() 返回 null。将其 _rev 传给 restore(),以便并发更改返回冲突,而不是恢复过时状态。

创建并分配分类术语

taxonomies:write 允许插件创建术语并应用分配增量。传入术语行 ID 或翻译组 ID。不接受术语 slug,因为它们按分类法与语言限定。

以下示例创建子类别并将其分配,而不替换条目的其他类别:

const releaseNotes = await ctx.taxonomies!.createTerm!("category", {
	label: "Release notes",
	parentId: productUpdatesId,
	locale: "en",
});

await ctx.taxonomies!.addEntryTerms!("posts", postId, "category", [releaseNotes.id]);

addEntryTerms() 与 removeEntryTerms() 是幂等的集合增量。并发添加会保留各项分配。EmDash 检查分类法是否已附加到集合、条目是否存在,以及每个术语是否属于指定的分类法。当分类法不是层级式时,createTerm() 会拒绝 parentId,而不是忽略它。使用 translationOf 创建翻译术语会加入源术语的翻译组;源必须属于同一分类法,且该组每种语言只能有一个术语。

分类法定义创建、集合附加、替换、术语更新与术语删除不通过 taxonomies:write 提供。

读取媒体元数据与字节

media:read 返回就绪的媒体记录,包含尺寸、替代文本、说明文字、焦点、blurhash、主色、文件夹 ID,以及基于 ID 的已认证资产 URL。具有 media:read 权限的已认证调用方可跟随该 URL;未登录请求会在路由读取媒体记录之前被拒绝。元数据不返回存储键、作者身份、内容哈希或文件字节。内容哈希仅在 readBytes() 中可用,因为它可能暴露站点是否存储了某个已知文件。

在钩子或路由处理器中,以下调用最多读取 2 MiB 的就绪媒体项:

const file = await ctx.media!.readBytes!(mediaId, {
	maxBytes: 2 * 1024 * 1024,
});

const digest = file.contentHash;
const bytes = file.bytes;

readBytes() 会缓冲结果。省略 maxBytes 时默认为 10 MiB,并拒绝高于主机最大值 16 MiB 的值。EmDash 在消费存储流时计数字节,因此错误的已存储大小无法绕过请求的上限。缺失、待处理与失败的媒体会被拒绝,且不暴露其存储位置。

以下更新更改无访问性文本与焦点,而不授予上传、替换或删除权限:

const updated = await ctx.media!.updateMetadata!(mediaId, {
	alt: "Two people reviewing a printed proof",
	focalX: 0.42,
	focalY: 0.36,
});

将两个焦点坐标都作为 0 到 1 的数字提供,或将两者都设为 null。对不同元数据字段的并发补丁不会互相覆盖。

上传媒体

ctx.media.upload() 接受图像、视频、音频与 PDF 内容,并对任何其他内容类型抛错。在受信任插件中,upload() 与 getUploadUrl() 强制执行默认媒体上传允许列表:PNG、JPEG、GIF、WebP 与 AVIF 图像、任何 video/* 或 audio/* 类型,以及 application/pdf。其他类型抛出状态为 415 的 PluginRouteError,格式错误的内容类型抛出状态为 400 的错误;路由处理器可将任一错误作为响应向上传播。在受信任插件中,由 upload() 存储或由 getUploadUrl() 预留的文件还会采用与内容类型匹配的扩展名,无论文件名扩展名是什么;当内容类型没有已知扩展名时,仅当文件名扩展名属于允许的媒体类型时才会保留。沙箱插件在文件名扩展名为 1–10 个字母或数字时保留该扩展名。

安全地管理重定向

redirects:read 提供基于游标的分页规则列表,以及单条规则的版本化读取。当插件创建、更新或删除规则时,添加 redirects:write。写访问可以改变访客被发送到的位置。

更新或删除规则时,原样传入 get()、create() 或 update() 返回的 _rev。EmDash 会拒绝过时的修订,以便插件可以重新读取规则并重新计算其更改,而不是覆盖并发工作。

修订跟踪重定向配置。访问计数不会使修订过时。

以下示例仅在自读取以来未更改时更新重定向:

const current = await ctx.redirects!.get(redirectId);
if (current) {
	await ctx.redirects!.update!(redirectId, {
		destination: "/guides/current",
		_rev: current._rev,
	});
}

创建操作对路径模式、终端 410 与 451 规则、重复源、自循环以及多跳循环使用与 EmDash 重定向 API 相同的规则进行验证。当源或目标更改时,更新会应用循环验证。仅启用的更新可能重新激活预先存在的循环,Redirects 页面会报告这一点。auto 标记属于从主机内容变更创建的重定向;插件输入不能设置它。

读取与审核评论

comments:read 授予对未移入回收站的评论的访问权限。结果包括作者姓名与电子邮件地址、评论正文、假名化 IP 哈希、用户代理、审核元数据、状态、目标内容 ID 与时间戳。它们排除关联的 EmDash 用户账户 ID。当插件还需要查找用户账户时,请单独声明 users:read。

list() 先返回最新评论。它接受 status、collection 与 contentId 过滤器、游标,以及 1 到 100 的限制。默认限制为 50。count() 接受相同的过滤器,不含分页。

以下路由仅在评论仍为待处理时批准它:

const comment = await ctx.comments!.setStatus!(commentId, "approved", {
	expectedStatus: "pending",
});

若另一位审核者在插件读取后更改了状态,setStatus() 会以 COMMENT_STATUS_CONFLICT 拒绝。再次读取评论并重新计算决定后再重试。在先前转换的状态可见之前重叠的请求会以 COMMENT_MODERATION_IN_PROGRESS 拒绝;等待该转换完成,然后读取当前评论再重试。成功的转换会以 origin: { source: "plugin", pluginId } 运行一次 comment:afterModerate。批准会发送与管理员批准相同的核心作者通知。将评论设为其当前状态是空操作,不会运行钩子或发送另一次通知。

网络主机允许列表

具有 network:request 的插件只能获取列在 allowedHosts 中的主机。前导的 *. 同时匹配指定域名及其子域名:

"capabilities": ["network:request"],
"allowedHosts": [
	"api.example.com",     // exact host
	"*.cdn.example.com"    // cdn.example.com and any subdomain
]

Bridge 在转发请求之前,会对照允许列表检查请求 URL 的主机。对未声明主机的请求会在插件内抛错,而不会离开沙箱。

network:request:unrestricted 绕过清单主机列表。沙箱 bridge 仍只接受 HTTP 与 HTTPS,阻止已知的内部主机与字面私有地址,重新检查每次重定向,并在跨源重定向时剥离凭证头。仅在运营者在运行时提供目标时使用不受限制的访问。对于固定目标,声明带有显式主机的 network:request,以便同意对话框能点名这些主机。

ctx.http.fetch() 会缓冲请求与响应体,并将每个解码后的主体限制为 8 MiB。返回的 WHATWG Response 在两个沙箱 runner 上都保留二进制字节、状态文本、头、最终 URL、重定向状态与 clone() 行为。使用 arrayBuffer() 或 blob() 读取二进制数据。

沙箱强制执行什么

当沙箱 runner 处于活动状态时,运行时会强制执行:

  1. 按 capability 门控。 PluginContext 工厂仅在声明了相应 capability 时填充 ctx.content、ctx.comments、ctx.schema、ctx.taxonomies、ctx.redirects、ctx.media、ctx.http、ctx.users、ctx.email。无法在未声明的 capability 上调用方法——那里根本没有对象。

  2. Storage 与 KV 作用域。 每一次 storage 与 KV 操作都限定在运行时的插件 ID 内。插件无法读取其他插件的 KV 或 storage 集合,且只能访问其清单中声明的集合。

  3. 网络隔离。 Runner 会阻止直接的 fetch() 及其他网络原语。到达网络的唯一方式是 ctx.http.fetch(),它会经过 bridge 的主机验证。

  4. 无主机绑定。 沙箱插件看不到环境变量、文件系统或平台绑定——即使其主机 worker 拥有它们。插件运行时是一个干净的 isolate,仅包含 bridge 与已声明的 capabilities。

  5. 资源限制。 Cloudflare runner 默认为每次调用 50 ms CPU、10 个子请求与 30 秒挂钟时间。Worker Loader 强制执行 CPU 与子请求;runner 强制执行挂钟时间。Worker Loader 有平台内存上限,但其按插件的 memoryMb 选项目前不可强制执行。Node.js workerd runner 仅强制执行默认的 30 秒挂钟时间;当站点配置了独立 workerd 无法强制执行的 CPU、内存或子请求限制时,它会发出警告。按钩子的 timeout 仅在进程内运行的沙箱格式插件上适用。

沙箱不强制执行什么

能力系统不覆盖也无法覆盖的一些内容:

  • 已授予 capability 内的行为。 具有 content:write 的插件可以编辑任何内容,而不仅是它自己的。Capabilities 是粗粒度的——它们表示「此插件可以写入内容」,而不是「此插件只能写入它创建的内容」。运营者在授予该访问权限之前应评估插件的代码与发布者。
  • 条目编辑锁。 ctx.content.update() 与 ctx.content.delete() 是程序化写入。持有条目咨询性编辑锁的编辑者不会阻止它们。当两者都可能更新同一条目时,请与编辑者协调插件写入。
  • Node.js 上的运营者信任。 当已配置的沙箱 runner 报告不可用时(没有 Cloudflare Worker Loader、没有安装 Node 侧 runner 等),sandboxed: [] 插件会在启动时被跳过。你可以将它们移到 plugins: [] 以在进程内运行——但那样就没有 V8 isolate、没有资源限制,插件可以直接调用 fetch() 或读取环境变量。将此视为原生级信任。
  • 侧信道。 时序、日志输出与存储数据对具有适当主机环境访问权限的人可见。不要将沙箱用作对抗运行它的运营者的机密性边界。

Capability 同意

当运营者从注册表安装沙箱插件时,EmDash 会显示列出已声明 capabilities 的同意对话框。添加 capabilities 的更新——例如,之前仅读取内容、现在想发起网络请求的插件——会显示为 capability 差异,并需要新的批准后新版本才会生效。

为可能的未来用途声明 capabilities,会使每次安装或更新都请求不必要的访问。列出当前版本使用的内容,然后在开始使用该项 capability 的版本中再添加它。

打包时验证

emdash-plugin bundle 与 emdash-plugin publish 会运行额外检查:

  • 每个已声明的 capability 都必须在已识别集合中(拼写错误会使构建失败)。
  • network:request 需要非空的 allowedHosts;network:request:unrestricted 需要它为空。参见 Capabilities and hosts。
  • 打包的 backend.js 不能导入 Node.js 内置模块(fs、path、child_process 等)——沙箱运行时不提供它们。

参见 the manifest reference 了解编写字段,以及 Bundling and publishing 了解打包检查。