Capability とセキュリティ

このページ

サンドボックスプラグインはデフォルトで隔離されています。独自の KV とストレージの読み書きを超えることを行うには、プラグインは マニフェスト で capability を宣言 する必要があります。サンドボックスブリッジは、それらの宣言に基づいてホスト提供のすべての API をゲートします。content:read を宣言していないプラグインは ctx.content を得られず、network:request を宣言していないプラグインは ctx.http を得られません。

このページでは、各 capability が何を付与するか、サンドボックスがどのように強制するか、強制できないものは何かを扱います。

Capability を宣言する

Capability は emdash-plugin.jsonc にあり、slug と信頼契約の他の部分と並びます。

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

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

プラグインが実際に必要なものだけを宣言してください。レジストリはインストール前にこれらの capability をサイト運営者に表示するため、余分な宣言はすべて、プラグインが使わないアクセスの承認を求めます。

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:registerポリシーフック content: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:writealt テキスト、キャプション、焦点向けの 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:registeremail:beforeSend / email:afterSend フックの登録を許可
hooks.page-fragments:registerpage:fragments フックの登録を許可(ネイティブプラグインのみ)

次のルールは、プラグインが必要とする capability に影響します。

  • 暗示。 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 を暗示します。
  • タクソノミーはコンテンツと別です。 タクソノミー capability は 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 を宣言できますが、ctx.email は別のプラグインが email:deliver トランスポートを登録した場合にのみ投入されます。

content:read は、著者 ID、翻訳グループ、リビジョンポインタ、行バージョンを含む安全なエントリ識別を返します。getTranslations() でロケール兄弟を発見し、getPublicUrl() でサイトのロケールと末尾スラッシュ規則に沿った公開ルートを解決します。getPublicUrl() は下書き、ルーティング不可のコレクション、欠落スラッグ、サイトが提供しないロケールに対して null を返します。プレビュー URL は決して返しません。

リビジョンスナップショットには、管理者が後で削除したフィールド値が含まれることがあります。保持された履歴が必要な場合にのみ content:revisions:read を宣言してください。リビジョン結果はリビジョン著者の識別を省略します。

schema:read は、データベース ID、タイムスタンプ、移行メタデータ、SQL カラム型なしでコレクションとフィールド定義を公開します。非表示コレクションは、hidden がデータアクセスではなく管理ナビゲーションを制御するため、引き続き可視です。

コンテンツの作成と翻訳

ctx.content.create() は、新しいエントリのロケール用の省略可能な第 3 引数を受け取ります。

const post = await ctx.content.create(
	"posts",
	{ title: "繁體中文" },
	{ locale: "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 フックに再入せず、保存フック内から作成されたコンテンツは保存フックを再実行しません。

各翻訳グループはロケールごとに 1 つのアクティブエントリを含められます。同じグループとロケールに 2 つ目のエントリを作成すると CONFLICT エラーになります。ソース欠落は NOT_FOUND、無効または未設定のロケールは VALIDATION_ERROR、保存フックは SAVE_REJECTED で作成を止められます。

公開状態を変更する

エントリを公開・非公開・スケジュール・スケジュール解除するには content:publish を宣言します。各アクションは getVersioned() または直前のアクションが返す不透明な _rev を必要とします。EmDash はこれらのメソッドを、REST および MCP アクションと同じポリシーフック、リビジョン昇格、ロケール同期、リダイレクト、メディア使用更新、キャッシュ無効化、アフターフック経由でルーティングします。

次のルートは、読み取り以降にエントリが変わっていない場合にのみ現在の下書きを公開します。

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 を渡します。タームスラッグはタクソノミーとロケールでスコープされるため受け付けられません。

次の例は子カテゴリを作成し、エントリの他のカテゴリを置き換えずに割り当てます。

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 で翻訳タームを作成するとソースタームの翻訳グループに加わります。ソースは同じタクソノミーに属し、グループはロケールごとに 1 タームのみ含められます。

タクソノミー定義の作成、コレクション添付、置換、ターム更新、ターム削除は taxonomies:write では利用できません。

メディアメタデータとバイトの読み取り

media:read は、寸法、alt テキスト、キャプション、焦点、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,
	});
}

作成操作は、EmDash リダイレクト API と同じ規則でパスパターン、終端の 410 と 451 ルール、重複ソース、自己ループ、マルチホップループを検証します。更新はソースまたは宛先が変わるときにループ検証を適用します。有効化のみの更新は既存のループを再有効化でき、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 を 1 回実行します。承認は管理者承認と同じコア著者通知を送ります。コメントを現在のステータスに設定するのは no-op で、フックを実行せず別の通知も送りません。

ネットワークホスト許可リスト

network:request を持つプラグインは、allowedHosts に列挙されたホストのみを取得できます。先頭の *. は名前付きドメインとそのサブドメインの両方に一致します。

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

ブリッジはリクエストを転送する前に、リクエスト URL のホストを許可リストと照合します。宣言されていないホストへのリクエストは、サンドボックスを出ることなくプラグイン内でスローします。

network:request:unrestricted はマニフェストのホスト許可リストをスキップします。サンドボックスブリッジは引き続き HTTP と HTTPS のみを受け付け、既知の内部ホストとプライベートリテラルアドレスをブロックし、すべてのリダイレクトを再確認し、リダイレクトがオリジンをまたぐときに資格情報ヘッダーを削除します。制限なしアクセスは、運営者がランタイムで宛先を提供する場合にのみ使ってください。固定宛先には明示ホスト付きの network:request を宣言し、同意ダイアログがそれらを名前で示すようにします。

ctx.http.fetch() はリクエストとレスポンスの本文をバッファし、各デコード本文を 8 MiB に制限します。返される WHATWG Response は、両方のサンドボックスランナーでバイナリバイト、ステータステキスト、ヘッダー、最終 URL、リダイレクト状態、clone() 動作を保持します。バイナリデータは arrayBuffer() または blob() で読みます。

サンドボックスが強制すること

サンドボックスランナーが有効なとき、ランタイムは次を強制します。

  1. Capability ゲーティング。 PluginContext ファクトリは、対応する capability が宣言されている場合にのみ ctx.content、ctx.comments、ctx.schema、ctx.taxonomies、ctx.redirects、ctx.media、ctx.http、ctx.users、ctx.email を投入します。未宣言の capability のメソッドを呼ぶことはできません。そこにオブジェクトはありません。

  2. ストレージと KV のスコープ。 すべてのストレージと KV 操作はランタイムプラグイン ID にスコープされます。プラグインは他のプラグインの KV やストレージコレクションを読めず、マニフェストで宣言されたコレクションにのみアクセスできます。

  3. ネットワーク隔離。 直接の fetch() とその他のネットワークプリミティブはランナーによってブロックされます。ネットワークに到達する唯一の方法は、ブリッジのホスト検証を通る ctx.http.fetch() です。

  4. ホストバインディングなし。 サンドボックスプラグインは環境変数、ファイルシステム、プラットフォームバインディングを見ません。ホストワーカーにあってもです。プラグインランタイムは、ブリッジと宣言された capability だけのクリーンなアイソレートです。

  5. リソース制限。 Cloudflare ランナーのデフォルトは、呼び出しあたり CPU 50 ms、サブリクエスト 10、ウォールタイム 30 秒です。Worker Loader は CPU とサブリクエストを強制し、ランナーはウォールタイムを強制します。Worker Loader にはプラットフォームメモリ上限がありますが、プラグインごとの memoryMb オプションは現在強制できません。Node.js workerd ランナーは 30 秒ウォールタイムのデフォルトのみを強制し、スタンドアロン workerd が強制できない CPU・メモリ・サブリクエスト制限をサイトが設定すると警告します。フックごとの timeout は、サンドボックス形式プラグインがプロセス内で実行される場合にのみ適用されます。

サンドボックスが強制しないこと

capability システムがカバーせず、カバーできないことがいくつかあります。

  • 付与された capability 内の挙動。 content:write を持つプラグインは、自分のものだけでなく任意のコンテンツを編集できます。Capability は粗いです。「このプラグインはコンテンツを書ける」と言い、「このプラグインが作成したコンテンツだけを書ける」とは言いません。そのアクセスを付与する前に、運営者はプラグインのコードとパブリッシャーを評価する必要があります。
  • エントリ編集ロック。 ctx.content.update() と ctx.content.delete() はプログラムによる書き込みです。エントリの助言的編集ロックを持つ編集者はそれらをブロックしません。両方が同じエントリを更新しうる場合は、プラグインの書き込みを編集者と調整してください。
  • Node.js での運営者信頼。 設定されたサンドボックスランナーが利用不可を報告する場合(Cloudflare Worker Loader なし、Node 側ランナー未インストールなど)、sandboxed: [] プラグインは起動時にスキップされます。それらを plugins: [] に移してプロセス内実行できますが、その場合 V8 アイソレートもリソース制限もなく、プラグインは直接 fetch() を呼んだり環境変数を読んだりできます。それをネイティブレベルの信頼として扱ってください。
  • サイドチャネル。 タイミング、ログ出力、保存データは、ホスト環境への適切なアクセスを持つ誰からも可視です。サンドボックスを、それを実行する運営者に対する機密性境界として使わないでください。

Capability 同意

運営者がレジストリからサンドボックスプラグインをインストールすると、EmDash は宣言された capability を列挙する同意ダイアログを表示します。capability を追加する更新 — たとえば以前はコンテンツを読むだけだったプラグインがネットワークリクエストをしたい場合 — は capability 差分として現れ、新バージョンが有効になる前に新たな承認が必要です。

将来の利用の可能性のために capability を宣言すると、すべてのインストールまたは更新が不要なアクセスを求めます。現在のバージョンが使うものを列挙し、使い始めるバージョンで 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 を参照してください。