能力與安全性

本頁內容

沙箱外掛預設是隔離的。除了讀寫自己的 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 了解打包檢查。