Capabilities et sécurité

Sur cette page

Les plugins sandboxed sont isolés par défaut. Pour faire quoi que ce soit au-delà de la lecture et de l’écriture de leur propre KV et stockage, un plugin doit déclarer une capability dans son manifest. Le bridge du sandbox régule chaque API fournie par l’hôte selon ces déclarations — un plugin qui n’a pas déclaré content:read n’obtient pas de ctx.content, et un qui n’a pas déclaré network:request n’obtient pas de ctx.http.

Cette page couvre ce que chaque capability accorde, comment le sandbox les applique, et ce qui n’est pas applicable.

Déclarer des capabilities

Les capabilities vivent dans emdash-plugin.jsonc, aux côtés de slug et du reste du contrat de confiance :

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

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

Déclarez uniquement ce dont le plugin a réellement besoin. Le registre montre ces capabilities aux opérateurs de site avant l’installation, donc chaque déclaration supplémentaire leur demande d’approuver un accès que le plugin n’utilise pas.

Référence des capabilities

CapabilityAccorde l’accès à
content:readctx.content.get(), ctx.content.list(), ctx.content.getTranslations(), ctx.content.getPublicUrl()
content:revisions:readctx.content.listRevisions(), ctx.content.getRevision() (implique content:read)
content:writectx.content.create(), ctx.content.update(), ctx.content.delete() (implique content:read)
content:publishOpérations versionnées de publier, dépublier, planifier et déplanifier (implique content:read)
content:restoreLire et restaurer le contenu mis à la corbeille
comments:readctx.comments.get(), ctx.comments.list(), ctx.comments.count() et données personnelles des commentaires
comments:moderatectx.comments.setStatus() avec contrôle de concurrence de statut attendu (implique comments:read)
schema:readctx.schema.listCollections(), ctx.schema.getCollection()
hooks.content-policy:registerHooks de politique content:beforePublish, content:beforeSchedule et content:beforeUnpublish
taxonomies:readctx.taxonomies.getAll(), ctx.taxonomies.getTerms(), ctx.taxonomies.getEntryTerms()
taxonomies:writectx.taxonomies.createTerm(), ctx.taxonomies.addEntryTerms(), ctx.taxonomies.removeEntryTerms() (implique taxonomies:read)
redirects:readctx.redirects.list(), ctx.redirects.get()
redirects:writectx.redirects.create(), ctx.redirects.update(), ctx.redirects.delete() (implique redirects:read)
media:readctx.media.get(), ctx.media.list()
media:bytes:readctx.media.readBytes() pour les médias prêts, avec une réponse mises en tampon bornée
media:metadata:writectx.media.updateMetadata() pour le texte alternatif, les légendes et les points focaux
media:writectx.media.getUploadUrl(), ctx.media.upload(), ctx.media.delete() (implique media:read)
network:requestctx.http.fetch() — restreint à allowedHosts
network:request:unrestrictedctx.http.fetch() sans restriction d’hôte (uniquement pour les URL configurées par l’utilisateur)
users:readctx.users.get(), ctx.users.getByEmail(), ctx.users.list()
email:sendctx.email.send() (nécessite un plugin fournisseur d’e-mail configuré)
hooks.email-transport:registerPermet d’enregistrer le hook exclusif email:deliver (fournisseurs de transport)
hooks.email-events:registerPermet d’enregistrer les hooks email:beforeSend / email:afterSend
hooks.page-fragments:registerPermet d’enregistrer le hook page:fragments (plugins natifs uniquement)

Les règles suivantes affectent les capabilities dont un plugin a besoin :

  • Implications. content:write, content:revisions:read et content:publish impliquent automatiquement content:read ; comments:moderate implique comments:read ; taxonomies:write implique taxonomies:read ; media:write implique media:read ; redirects:write implique redirects:read ; network:request:unrestricted implique network:request. Vous n’avez pas besoin de lister les deux.
  • Les autorités médias sont séparées. media:read, media:bytes:read et media:metadata:write ne s’impliquent pas mutuellement. Déclarez chaque opération utilisée par le plugin. La capability existante media:write continue d’impliquer media:read pour la compatibilité.
  • Les taxonomies sont séparées du contenu. Les capabilities de taxonomie n’accordent ni content:read ni content:write. Déclarez la capability de contenu correspondante si le plugin lit ou édite aussi des champs d’entrée.
  • La politique de publication est séparée de l’accès au contenu. hooks.content-policy:register permet à un plugin d’inspecter et de rejeter les changements d’état de publication via des événements de hook de politique. Elle ne fournit pas ctx.content et n’accorde pas d’actions d’édition ou de publication de contenu.
  • network:request:unrestricted existe pour les URL configurées par l’utilisateur. Un plugin webhook où l’opérateur saisit l’URL de destination doit atteindre des hôtes absents du manifest. Les plugins qui appellent toujours des API connues doivent utiliser network:request + allowedHosts.
  • email:send est régulé par la configuration, pas seulement par la capability. Un plugin peut déclarer email:send, mais ctx.email ne sera peuplé que si un autre plugin a enregistré un transport email:deliver.

content:read renvoie une identité d’entrée sûre, y compris l’ID d’auteur, le groupe de traduction, les pointeurs de révision et la version de ligne. Utilisez getTranslations() pour découvrir les frères de locale et getPublicUrl() pour résoudre une route publiée avec les règles de locale et de barre oblique finale du site. getPublicUrl() renvoie null pour les brouillons, les collections non routables, les slugs manquants et les locales que le site ne sert pas. Il ne renvoie jamais une URL d’aperçu.

Les instantanés de révision peuvent contenir des valeurs de champ qu’un administrateur a ensuite retirées. Déclarez content:revisions:read uniquement lorsque le plugin a besoin de l’historique conservé. Les résultats de révision omettent l’identité de l’auteur de la révision.

schema:read expose les définitions de collection et de champ sans IDs de base, horodatages, métadonnées de migration ni types de colonne SQL. Les collections masquées restent visibles car hidden contrôle la navigation d’administration plutôt que l’accès aux données.

Créer et traduire du contenu

ctx.content.create() accepte un troisième argument optionnel pour la locale de la nouvelle entrée :

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

La correspondance de locale est insensible à la casse et stocke la casse de la configuration de locale du site, de sorte que zh-tw devient zh-TW lorsque c’est la forme configurée. Une locale explicite malformée lève toujours une erreur ; lorsque i18n est configuré, une locale explicite hors de la liste de locales configurée lève aussi. Lorsque l’option est omise, EmDash utilise la locale par défaut configurée du site ; les sites sans configuration i18n conservent le défaut en.

Pour ajouter une locale à une entrée existante, passez son ID de base comme translationOf :

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

La source doit être une entrée active dans la même collection. La nouvelle entrée rejoint son groupe de traduction, hérite de ses crédits de byline et assignations de taxonomie, et démarre avec les valeurs source pour les champs marqués comme non traduisibles. Une valeur fournie pour un champ non traduisible ne remplace pas la valeur source pendant la création de la traduction. La validation de contenu et les hooks de sauvegarde s’exécutent via le même chemin runtime que les autres créations de contenu. EmDash ne réentre pas dans le propre hook content:afterSave du plugin créateur, et le contenu créé depuis un hook de sauvegarde n’exécute pas à nouveau les hooks de sauvegarde.

Chaque groupe de traduction peut contenir une entrée active par locale. Créer une deuxième entrée pour le même groupe et la même locale lève une erreur CONFLICT. Une source manquante lève NOT_FOUND, une locale invalide ou non configurée lève VALIDATION_ERROR, et un hook de sauvegarde peut arrêter la création avec SAVE_REJECTED.

Modifier l’état de publication

Déclarez content:publish pour publier, dépublier, planifier ou déplanifier une entrée. Chaque action exige le _rev opaque renvoyé par getVersioned() ou l’action précédente. EmDash achemine ces méthodes via les mêmes hooks de politique, promotion de révision, synchronisation de locale, redirections, mises à jour d’usage des médias, invalidation de cache et after-hooks que les actions REST et MCP.

La route suivante publie le brouillon actuel uniquement lorsque l’entrée n’a pas changé depuis sa lecture :

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() accepte { scheduledAt, _rev } ; les autres méthodes de publication acceptent { _rev }. Ces méthodes n’acceptent pas un override de publishedAt.

Déclarez content:restore séparément pour lire et restaurer les entrées mises à la corbeille. getTrashedVersioned() renvoie null pour une entrée live ou manquante. Passez son _rev à restore() pour qu’un changement concurrent renvoie un conflit au lieu de restaurer un état obsolète.

Créer et assigner des termes de taxonomie

taxonomies:write permet à un plugin de créer des termes et d’appliquer des deltas d’assignation. Passez des IDs de ligne de terme ou des IDs de groupe de traduction. Les slugs de terme ne sont pas acceptés car ils sont limités par taxonomie et locale.

L’exemple suivant crée une catégorie enfant et l’assigne sans remplacer les autres catégories de l’entrée :

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

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

addEntryTerms() et removeEntryTerms() sont des deltas d’ensemble idempotents. Les ajouts concurrents conservent chaque assignation. EmDash vérifie que la taxonomie est attachée à la collection, que l’entrée existe et que chaque terme appartient à la taxonomie nommée. createTerm() rejette parentId lorsque la taxonomie n’est pas hiérarchique au lieu de l’ignorer. Créer un terme traduit avec translationOf rejoint le groupe de traduction du terme source ; la source doit appartenir à la même taxonomie, et le groupe ne peut contenir qu’un terme par locale.

La création de définitions de taxonomie, l’attachement à une collection, le remplacement, les mises à jour de termes et la suppression de termes ne sont pas disponibles via taxonomies:write.

Lire les métadonnées et octets de médias

media:read renvoie des enregistrements de médias prêts avec dimensions, texte alternatif, légende, point focal, blurhash, couleur dominante, ID de dossier et une URL d’asset authentifiée basée sur l’ID. Les appelants authentifiés avec la permission media:read peuvent suivre l’URL ; les requêtes déconnectées sont rejetées avant que la route ne lise l’enregistrement média. Les métadonnées ne renvoient pas la clé de stockage, l’identité de l’auteur, le hash de contenu ni les octets du fichier. Le hash de contenu n’est disponible que via readBytes() car il peut révéler si le site stocke un fichier connu.

Dans un hook ou un gestionnaire de route, l’appel suivant lit au plus 2 MiB d’un élément média prêt :

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

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

readBytes() met le résultat en tampon. Il utilise 10 MiB par défaut lorsque maxBytes est omis et rejette les valeurs au-dessus du maximum hôte de 16 MiB. EmDash compte les octets en consommant le stream de stockage, de sorte qu’une taille stockée incorrecte ne peut pas contourner la limite demandée. Les médias manquants, en attente et échoués sont rejetés sans révéler leur emplacement de stockage.

La mise à jour suivante change le texte d’accessibilité et le point focal sans accorder d’autorité de téléversement, de remplacement ou de suppression :

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

Fournissez les deux coordonnées focales comme nombres de 0 à 1, ou définissez les deux à null. Les correctifs concurrents sur des champs de métadonnées différents ne se remplacent pas.

Téléverser des médias

ctx.media.upload() accepte le contenu image, vidéo, audio et PDF et lève une erreur pour tout autre type de contenu. Dans un plugin de confiance, upload() et getUploadUrl() appliquent la liste d’autorisations de téléversement de médias par défaut : images PNG, JPEG, GIF, WebP et AVIF, tout type video/* ou audio/*, et application/pdf. Les autres types lèvent un PluginRouteError avec le statut 415, et un type de contenu malformé en lève un avec le statut 400 ; un gestionnaire de route peut laisser l’un ou l’autre se propager comme réponse. Dans un plugin de confiance, un fichier stocké par upload() ou réservé par getUploadUrl() prend aussi une extension correspondant au type de contenu, quelle que soit l’extension du nom de fichier ; lorsque le type de contenu n’a pas d’extension connue, l’extension du nom de fichier n’est conservée que si elle appartient à un type de média autorisé. Un plugin sandboxed conserve l’extension du nom de fichier lorsqu’elle a de 1 à 10 lettres ou chiffres.

Gérer les redirections en toute sécurité

redirects:read fournit un listage de règles paginé par curseur et des lectures versionnées d’une seule règle. Ajoutez redirects:write lorsque le plugin crée, met à jour ou supprime des règles. L’accès en écriture peut changer où les visiteurs sont envoyés.

Passez le _rev renvoyé par get(), create() ou update() inchangé lors de la mise à jour ou de la suppression d’une règle. EmDash rejette une révision obsolète pour que le plugin puisse relire la règle et recalculer son changement au lieu d’écraser un travail concurrent.

La révision suit la configuration de redirection. Le comptage des visites ne rend pas une révision obsolète.

L’exemple suivant met à jour une redirection uniquement si elle n’a pas changé depuis la lecture :

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

Les opérations de création valident les motifs de chemin, les règles terminales 410 et 451, les sources en double, les boucles sur soi et les boucles multi-sauts avec les mêmes règles que l’API de redirection EmDash. Les mises à jour appliquent la validation des boucles lorsque la source ou la destination change. Une mise à jour uniquement d’activation peut réactiver une boucle préexistante, que la page Redirects signale. Le marqueur auto appartient aux redirections créées à partir de changements de contenu de l’hôte ; l’entrée du plugin ne peut pas le définir.

Lire et modérer les commentaires

comments:read accorde l’accès aux commentaires non mis à la corbeille. Les résultats incluent le nom et l’adresse e-mail de l’auteur, le corps du commentaire, le hash d’IP pseudonyme, l’agent utilisateur, les métadonnées de modération, le statut, les IDs de contenu cible et les horodatages. Ils excluent l’ID de compte utilisateur EmDash lié. Déclarez users:read séparément lorsqu’un plugin doit aussi rechercher des comptes utilisateur.

list() renvoie d’abord les commentaires les plus récents. Il accepte les filtres status, collection et contentId, un curseur et une limite de 1 à 100. La limite par défaut est 50. count() accepte les mêmes filtres sans pagination.

La route suivante approuve un commentaire uniquement s’il est encore en attente :

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

Si un autre modérateur a changé le statut après que le plugin l’a lu, setStatus() rejette avec COMMENT_STATUS_CONFLICT. Relisez le commentaire et recalculez la décision avant de réessayer. Une requête qui chevauche une transition antérieure avant que son statut ne soit visible rejette avec COMMENT_MODERATION_IN_PROGRESS ; attendez la fin de cette transition, puis lisez le commentaire actuel avant de réessayer. Une transition réussie exécute comment:afterModerate une fois avec origin: { source: "plugin", pluginId }. L’approbation envoie la même notification d’auteur du noyau qu’une approbation d’administrateur. Définir un commentaire à son statut actuel est un no-op et n’exécute pas le hook ni n’envoie une autre notification.

Listes d’hôtes réseau autorisés

Les plugins avec network:request ne peuvent récupérer que les hôtes listés dans allowedHosts. Un *. initial correspond à la fois au domaine nommé et à ses sous-domaines :

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

Le bridge vérifie l’hôte de l’URL de la requête contre la liste d’autorisations avant de transmettre la requête. Une requête vers un hôte non déclaré lève une erreur dans le plugin sans jamais quitter le sandbox.

network:request:unrestricted ignore la liste d’hôtes du manifest. Le bridge du sandbox n’accepte toujours que HTTP et HTTPS, bloque les hôtes internes connus et les adresses littérales privées, revérifie chaque redirection et retire les en-têtes d’identifiants lorsqu’une redirection croise des origines. Utilisez l’accès non restreint uniquement lorsqu’un opérateur fournit la destination à l’exécution. Pour les destinations fixes, déclarez network:request avec des hôtes explicites pour que la boîte de dialogue de consentement les nomme.

ctx.http.fetch() met en tampon les corps de requête et de réponse et limite chaque corps décodé à 8 MiB. La Response WHATWG renvoyée préserve les octets binaires, le texte de statut, les en-têtes, l’URL finale, l’état de redirection et le comportement de clone() dans les deux runners sandbox. Lisez les données binaires avec arrayBuffer() ou blob().

Ce que le sandbox applique

Lorsqu’un sandbox runner est actif, le runtime applique :

  1. Régulation par capability. L’usine PluginContext ne peuple ctx.content, ctx.comments, ctx.schema, ctx.taxonomies, ctx.redirects, ctx.media, ctx.http, ctx.users, ctx.email que lorsque la capability correspondante est déclarée. Appeler une méthode sur une capability non déclarée est impossible — il n’y a pas d’objet là.

  2. Portée du stockage et du KV. Chaque opération de stockage et de KV est limitée à l’ID de plugin du runtime. Un plugin ne peut pas lire le KV ou les collections de stockage d’un autre plugin, et il ne peut accéder qu’aux collections déclarées dans son manifest.

  3. Isolation réseau. Le fetch() direct et les autres primitives réseau sont bloqués par le runner. Le seul moyen d’atteindre le réseau est ctx.http.fetch(), qui passe par la validation d’hôte du bridge.

  4. Pas de bindings d’hôte. Les plugins sandboxed ne voient ni les variables d’environnement, ni le système de fichiers, ni les bindings de plateforme — même si votre worker hôte les a. Le runtime du plugin est un isolate propre avec seulement le bridge et les capabilities déclarées.

  5. Limites de ressources. Le runner Cloudflare utilise par défaut 50 ms de CPU, 10 sous-requêtes et 30 secondes de temps mur par invocation. Worker Loader applique le CPU et les sous-requêtes ; le runner applique le temps mur. Worker Loader a un plafond mémoire de plateforme, mais son option memoryMb par plugin n’est actuellement pas applicable. Le runner workerd de Node.js n’applique que le défaut de 30 secondes de temps mur ; il avertit lorsqu’un site configure des limites CPU, mémoire ou de sous-requêtes que le workerd autonome ne peut pas appliquer. Un timeout par hook ne s’applique que lorsque le plugin au format sandboxed s’exécute en processus.

Ce que le sandbox n’applique pas

Quelques points que le système de capabilities ne couvre pas et ne peut pas couvrir :

  • Comportement au sein d’une capability accordée. Un plugin avec content:write peut éditer n’importe quel contenu, pas seulement le sien. Les capabilities sont grossières — elles disent « ce plugin peut écrire du contenu », pas « ce plugin ne peut écrire que le contenu qu’il a créé ». Un opérateur doit évaluer le code et l’éditeur du plugin avant d’accorder cet accès.
  • Verrous d’édition d’entrée. ctx.content.update() et ctx.content.delete() sont des écritures programmatiques. Un éditeur qui détient le verrou d’édition consultatif de l’entrée ne les bloque pas. Coordonnez les écritures du plugin avec les éditeurs lorsque les deux peuvent mettre à jour la même entrée.
  • Confiance de l’opérateur sous Node.js. Lorsque le sandbox runner configuré signale indisponible (pas de Cloudflare Worker Loader, pas de runner côté Node installé, etc.), les plugins sandboxed: [] sont ignorés au démarrage. Vous pouvez les déplacer dans plugins: [] pour les exécuter en processus — mais alors il n’y a pas d’isolate V8, pas de limites de ressources, et le plugin peut appeler fetch() directement ou lire les variables d’environnement. Traitez cela comme une confiance de niveau natif.
  • Canaux latéraux. Le timing, la sortie des logs et les données stockées sont visibles pour quiconque a l’accès approprié à l’environnement hôte. N’utilisez pas le sandbox comme frontière de confidentialité contre l’opérateur qui le fait tourner.

Consentement aux capabilities

Lorsqu’un opérateur installe un plugin sandboxed depuis le registre, EmDash affiche une boîte de dialogue de consentement listant les capabilities déclarées. Les mises à jour qui ajoutent des capabilities — par exemple, un plugin qui ne lisait auparavant que le contenu et veut maintenant faire des requêtes réseau — apparaissent comme un diff de capabilities et exigent une nouvelle approbation avant que la nouvelle version ne prenne effet.

Déclarer des capabilities pour un usage futur possible fait que chaque installation ou mise à jour demande un accès inutile. Listez ce que la version actuelle utilise, puis ajoutez une capability dans la version qui commence à l’utiliser.

Validation au moment du bundle

emdash-plugin bundle et emdash-plugin publish effectuent des contrôles supplémentaires :

  • Chaque capability déclarée doit être dans l’ensemble reconnu (les fautes de frappe font échouer le build).
  • network:request exige un allowedHosts non vide ; network:request:unrestricted exige qu’il soit vide. Voir Capabilities and hosts.
  • Le backend.js empaqueté ne peut pas importer les built-ins Node.js (fs, path, child_process, etc.) — les runtimes sandbox ne les fournissent pas.

Voir the manifest reference pour les champs d’authoring et Bundling and publishing pour les contrôles de bundle.