Capabilities y seguridad

En esta página

Los plugins sandboxed están aislados por defecto. Para hacer algo más allá de leer y escribir su propio KV y almacenamiento, un plugin debe declarar una capability en su manifiesto. El bridge del sandbox regula cada API proporcionada por el host según esas declaraciones: un plugin que no declaró content:read no obtiene un ctx.content, y uno que no declaró network:request no obtiene un ctx.http.

Esta página cubre lo que concede cada capability, cómo el sandbox las aplica y qué no es aplicable.

Declarar capabilities

Las capabilities viven en emdash-plugin.jsonc, junto a slug y el resto del contrato de confianza:

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

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

Declara solo lo que el plugin realmente necesita. El registro muestra estas capabilities a los operadores del sitio antes de la instalación, así que cada declaración extra les pide aprobar un acceso que el plugin no usa.

Referencia de capabilities

CapabilityConcede acceso a
content:readctx.content.get(), ctx.content.list(), ctx.content.getTranslations(), ctx.content.getPublicUrl()
content:revisions:readctx.content.listRevisions(), ctx.content.getRevision() (implica content:read)
content:writectx.content.create(), ctx.content.update(), ctx.content.delete() (implica content:read)
content:publishOperaciones versionadas de publicar, despublicar, programar y desprogramar (implica content:read)
content:restoreLeer y restaurar contenido en la papelera
comments:readctx.comments.get(), ctx.comments.list(), ctx.comments.count() y datos personales de comentarios
comments:moderatectx.comments.setStatus() con control de concurrencia de estado esperado (implica comments:read)
schema:readctx.schema.listCollections(), ctx.schema.getCollection()
hooks.content-policy:registerHooks de política content:beforePublish, content:beforeSchedule y content:beforeUnpublish
taxonomies:readctx.taxonomies.getAll(), ctx.taxonomies.getTerms(), ctx.taxonomies.getEntryTerms()
taxonomies:writectx.taxonomies.createTerm(), ctx.taxonomies.addEntryTerms(), ctx.taxonomies.removeEntryTerms() (implica taxonomies:read)
redirects:readctx.redirects.list(), ctx.redirects.get()
redirects:writectx.redirects.create(), ctx.redirects.update(), ctx.redirects.delete() (implica redirects:read)
media:readctx.media.get(), ctx.media.list()
media:bytes:readctx.media.readBytes() para medios listos, con una respuesta en búfer acotada
media:metadata:writectx.media.updateMetadata() para texto alternativo, pies de foto y puntos focales
media:writectx.media.getUploadUrl(), ctx.media.upload(), ctx.media.delete() (implica media:read)
network:requestctx.http.fetch() — restringido a allowedHosts
network:request:unrestrictedctx.http.fetch() sin restricción de host (solo para URLs configuradas por el usuario)
users:readctx.users.get(), ctx.users.getByEmail(), ctx.users.list()
email:sendctx.email.send() (requiere un plugin proveedor de correo configurado)
hooks.email-transport:registerPermite registrar el hook exclusivo email:deliver (proveedores de transporte)
hooks.email-events:registerPermite registrar hooks email:beforeSend / email:afterSend
hooks.page-fragments:registerPermite registrar el hook page:fragments (solo plugins nativos)

Las siguientes reglas afectan qué capabilities necesita un plugin:

  • Implicaciones. content:write, content:revisions:read y content:publish implican automáticamente content:read; comments:moderate implica comments:read; taxonomies:write implica taxonomies:read; media:write implica media:read; redirects:write implica redirects:read; network:request:unrestricted implica network:request. No necesitas listar ambas.
  • Las autoridades de medios son separadas. media:read, media:bytes:read y media:metadata:write no se implican entre sí. Declara cada operación que use el plugin. La capability existente media:write sigue implicando media:read por compatibilidad.
  • Las taxonomías son separadas del contenido. Las capabilities de taxonomía no conceden content:read ni content:write. Declara la capability de contenido correspondiente si el plugin también lee o edita campos de entrada.
  • La política de publicación es separada del acceso al contenido. hooks.content-policy:register permite a un plugin inspeccionar y rechazar cambios de estado de publicación mediante eventos de hook de política. No proporciona ctx.content ni concede acciones de edición o publicación de contenido.
  • network:request:unrestricted existe para URLs configuradas por el usuario. Un plugin de webhook donde el operador escribe la URL de destino necesita alcanzar hosts que no están en el manifiesto. Los plugins que siempre llaman a APIs conocidas deben usar network:request + allowedHosts.
  • email:send está regulado por configuración, no solo por la capability. Un plugin puede declarar email:send, pero ctx.email solo se rellenará si algún otro plugin ha registrado un transporte email:deliver.

content:read devuelve una identidad de entrada segura, incluido el ID de autor, el grupo de traducción, los punteros de revisión y la versión de fila. Usa getTranslations() para descubrir hermanos de locale y getPublicUrl() para resolver una ruta publicada con las reglas de locale y barra final del sitio. getPublicUrl() devuelve null para borradores, colecciones no enrutables, slugs ausentes y locales que el sitio no sirve. Nunca devuelve una URL de vista previa.

Las instantáneas de revisión pueden contener valores de campo que un administrador eliminó después. Declara content:revisions:read solo cuando el plugin necesite el historial retenido. Los resultados de revisión omiten la identidad del autor de la revisión.

schema:read expone definiciones de colección y campo sin IDs de base de datos, marcas de tiempo, metadatos de migración ni tipos de columna SQL. Las colecciones ocultas siguen visibles porque hidden controla la navegación de administración, no el acceso a datos.

Crear y traducir contenido

ctx.content.create() acepta un tercer argumento opcional para el locale de la nueva entrada:

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

La coincidencia de locale no distingue mayúsculas y almacena el uso de mayúsculas de la configuración de locale del sitio, de modo que zh-tw se convierte en zh-TW cuando esa es la forma configurada. Un locale explícito malformado siempre lanza; cuando i18n está configurado, un locale explícito fuera de la lista de locales configurada también lanza. Cuando se omite la opción, EmDash usa el locale predeterminado configurado del sitio; los sitios sin configuración i18n conservan el predeterminado en.

Para añadir un locale a una entrada existente, pasa su ID de base de datos como translationOf:

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

La fuente debe ser una entrada activa en la misma colección. La nueva entrada se une a su grupo de traducción, hereda sus créditos de byline y asignaciones de taxonomía, y comienza con los valores de origen para los campos marcados como no traducibles. Un valor suministrado para un campo no traducible no sustituye el valor de origen durante la creación de la traducción. La validación de contenido y los hooks de guardado se ejecutan por la misma ruta de runtime que otras creaciones de contenido. EmDash no vuelve a entrar en el propio hook content:afterSave del plugin que crea, y el contenido creado desde dentro de un hook de guardado no vuelve a ejecutar hooks de guardado.

Cada grupo de traducción puede contener una entrada activa por locale. Crear una segunda entrada para el mismo grupo y locale lanza un error CONFLICT. Una fuente ausente lanza NOT_FOUND, un locale inválido o no configurado lanza VALIDATION_ERROR, y un hook de guardado puede detener la creación con SAVE_REJECTED.

Cambiar el estado de publicación

Declara content:publish para publicar, despublicar, programar o desprogramar una entrada. Cada acción requiere el _rev opaco devuelto por getVersioned() o la acción anterior. EmDash enruta estos métodos a través de los mismos hooks de política, promoción de revisión, sincronización de locale, redirecciones, actualizaciones de uso de medios, invalidación de caché y after-hooks que las acciones REST y MCP.

La siguiente ruta publica el borrador actual solo cuando la entrada no ha cambiado desde que se leyó:

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() acepta { scheduledAt, _rev }; los demás métodos de publicación aceptan { _rev }. Estos métodos no aceptan un override de publishedAt.

Declara content:restore por separado para leer y restaurar entradas en la papelera. getTrashedVersioned() devuelve null para una entrada activa o ausente. Pasa su _rev a restore() para que un cambio concurrente devuelva un conflicto en lugar de restaurar un estado obsoleto.

Crear y asignar términos de taxonomía

taxonomies:write permite a un plugin crear términos y aplicar deltas de asignación. Pasa IDs de fila de término o IDs de grupo de traducción. No se aceptan slugs de término porque están acotados por taxonomía y locale.

El siguiente ejemplo crea una categoría hija y la asigna sin reemplazar las demás categorías de la entrada:

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

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

addEntryTerms() y removeEntryTerms() son deltas de conjunto idempotentes. Las adiciones concurrentes conservan cada asignación. EmDash verifica que la taxonomía esté adjuntada a la colección, que la entrada exista y que cada término pertenezca a la taxonomía nombrada. createTerm() rechaza parentId cuando la taxonomía no es jerárquica en lugar de ignorarlo. Crear un término traducido con translationOf se une al grupo de traducción del término fuente; la fuente debe pertenecer a la misma taxonomía, y el grupo solo puede contener un término por locale.

La creación de definiciones de taxonomía, el adjunto a colecciones, el reemplazo, las actualizaciones de términos y la eliminación de términos no están disponibles a través de taxonomies:write.

Leer metadatos y bytes de medios

media:read devuelve registros de medios listos con dimensiones, texto alternativo, pie de foto, punto focal, blurhash, color dominante, ID de carpeta y una URL de activo autenticada basada en ID. Los llamadores autenticados con el permiso media:read pueden seguir la URL; las solicitudes sin sesión se rechazan antes de que la ruta lea el registro de medios. Los metadatos no devuelven la clave de almacenamiento, la identidad del autor, el hash de contenido ni los bytes del archivo. El hash de contenido solo está disponible desde readBytes() porque puede revelar si el sitio almacena un archivo conocido.

Dentro de un hook o manejador de ruta, la siguiente llamada lee como máximo 2 MiB de un elemento de medios listo:

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

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

readBytes() almacena el resultado en búfer. Por defecto es 10 MiB cuando se omite maxBytes y rechaza valores por encima del máximo del host de 16 MiB. EmDash cuenta bytes mientras consume el stream de almacenamiento, de modo que un tamaño almacenado incorrecto no puede eludir el límite solicitado. Los medios ausentes, pendientes y fallidos se rechazan sin revelar su ubicación de almacenamiento.

La siguiente actualización cambia el texto de accesibilidad y el punto focal sin conceder autoridad de subida, reemplazo o eliminación:

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

Proporciona ambas coordenadas focales como números de 0 a 1, o establece ambas en null. Los parches concurrentes a campos de metadatos distintos no se reemplazan entre sí.

Subir medios

ctx.media.upload() acepta contenido de imagen, vídeo, audio y PDF y lanza para cualquier otro tipo de contenido. En un plugin de confianza, upload() y getUploadUrl() aplican la lista de permitidos de subida de medios predeterminada: imágenes PNG, JPEG, GIF, WebP y AVIF, cualquier tipo video/* o audio/*, y application/pdf. Otros tipos lanzan un PluginRouteError con estado 415, y un tipo de contenido malformado lanza uno con estado 400; un manejador de ruta puede dejar que cualquiera se propague como respuesta. En un plugin de confianza, un archivo almacenado por upload() o reservado por getUploadUrl() también toma una extensión que coincida con el tipo de contenido, sea cual sea la extensión del nombre de archivo; cuando el tipo de contenido no tiene una extensión conocida, la extensión del nombre de archivo solo se conserva si pertenece a un tipo de medios permitido. Un plugin sandboxed conserva la extensión del nombre de archivo cuando tiene de 1 a 10 letras o dígitos.

Gestionar redirecciones de forma segura

redirects:read proporciona listado de reglas paginado por cursor y lecturas versionadas de una sola regla. Añade redirects:write cuando el plugin cree, actualice o elimine reglas. El acceso de escritura puede cambiar a dónde se envía a los visitantes.

Pasa el _rev devuelto por get(), create() o update() sin cambios al actualizar o eliminar una regla. EmDash rechaza una revisión obsoleta para que el plugin pueda volver a leer la regla y recalcular su cambio en lugar de sobrescribir trabajo concurrente.

La revisión rastrea la configuración de redirección. El conteo de visitas de visitantes no hace obsoleta una revisión.

El siguiente ejemplo actualiza una redirección solo si no ha cambiado desde la lectura:

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

Las operaciones de creación validan patrones de ruta, reglas terminales 410 y 451, fuentes duplicadas, bucles propios y bucles de varios saltos con las mismas reglas que la API de redirecciones de EmDash. Las actualizaciones aplican validación de bucles cuando cambia el origen o el destino. Una actualización solo de habilitación puede reactivar un bucle preexistente, que la página Redirects informa. El marcador auto pertenece a redirecciones creadas a partir de cambios de contenido del host; la entrada del plugin no puede establecerlo.

Leer y moderar comentarios

comments:read concede acceso a comentarios no enviados a la papelera. Los resultados incluyen el nombre y la dirección de correo del autor, el cuerpo del comentario, el hash de IP seudónimo, el agente de usuario, metadatos de moderación, estado, IDs de contenido de destino y marcas de tiempo. Excluyen el ID de cuenta de usuario EmDash vinculado. Declara users:read por separado cuando un plugin también necesite buscar cuentas de usuario.

list() devuelve primero los comentarios más recientes. Acepta filtros status, collection y contentId, un cursor y un límite de 1 a 100. El límite predeterminado es 50. count() acepta los mismos filtros sin paginación.

La siguiente ruta aprueba un comentario solo si sigue pendiente:

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

Si otro moderador cambió el estado después de que el plugin lo leyera, setStatus() rechaza con COMMENT_STATUS_CONFLICT. Vuelve a leer el comentario y recalcula la decisión antes de reintentar. Una solicitud que se solapa con una transición anterior antes de que su estado sea visible rechaza con COMMENT_MODERATION_IN_PROGRESS; espera a que esa transición termine y luego lee el comentario actual antes de reintentar. Una transición correcta ejecuta comment:afterModerate una vez con origin: { source: "plugin", pluginId }. La aprobación envía la misma notificación de autor del núcleo que una aprobación de administrador. Establecer un comentario en su estado actual es un no-op y no ejecuta el hook ni envía otra notificación.

Listas de hosts de red permitidos

Los plugins con network:request solo pueden obtener hosts listados en allowedHosts. Un *. inicial coincide tanto con el dominio nombrado como con sus subdominios:

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

El bridge comprueba el host de la URL de la solicitud contra la lista de permitidos antes de reenviar la solicitud. Una solicitud a un host que no se declaró lanza dentro del plugin sin salir nunca del sandbox.

network:request:unrestricted omite la lista de hosts permitidos del manifiesto. El bridge del sandbox sigue aceptando solo HTTP y HTTPS, bloquea hosts internos conocidos y direcciones literales privadas, vuelve a comprobar cada redirección y elimina cabeceras de credenciales cuando una redirección cruza orígenes. Usa el acceso sin restricciones solo cuando un operador suministra el destino en tiempo de ejecución. Para destinos fijos, declara network:request con hosts explícitos para que el diálogo de consentimiento los nombre.

ctx.http.fetch() almacena en búfer los cuerpos de solicitud y respuesta y limita cada cuerpo decodificado a 8 MiB. La Response WHATWG devuelta preserva bytes binarios, texto de estado, cabeceras, URL final, estado de redirección y el comportamiento de clone() en ambos runners de sandbox. Lee datos binarios con arrayBuffer() o blob().

Lo que el sandbox aplica

Cuando hay un sandbox runner activo, el runtime aplica:

  1. Regulación por capability. La fábrica de PluginContext solo rellena ctx.content, ctx.comments, ctx.schema, ctx.taxonomies, ctx.redirects, ctx.media, ctx.http, ctx.users, ctx.email cuando se declara la capability correspondiente. Llamar a un método de una capability no declarada no es posible: no hay objeto ahí.

  2. Ámbito de almacenamiento y KV. Cada operación de almacenamiento y KV está acotada al ID de plugin del runtime. Un plugin no puede leer el KV ni las colecciones de almacenamiento de otro plugin, y solo puede acceder a las colecciones declaradas en su manifiesto.

  3. Aislamiento de red. El fetch() directo y otros primitivos de red los bloquea el runner. La única forma de alcanzar la red es ctx.http.fetch(), que pasa por la validación de host del bridge.

  4. Sin bindings del host. Los plugins sandboxed no ven variables de entorno, el sistema de archivos ni bindings de plataforma, aunque tu worker host los tenga. El runtime del plugin es un isolate limpio solo con el bridge y las capabilities declaradas.

  5. Límites de recursos. El runner de Cloudflare usa por defecto 50 ms de CPU, 10 subrequests y 30 segundos de tiempo de pared por invocación. Worker Loader aplica CPU y subrequests; el runner aplica el tiempo de pared. Worker Loader tiene un techo de memoria de plataforma, pero su opción memoryMb por plugin no es actualmente aplicable. El runner workerd de Node.js solo aplica el valor predeterminado de 30 segundos de tiempo de pared; advierte cuando un sitio configura límites de CPU, memoria o subrequests que el workerd independiente no puede aplicar. Un timeout por hook solo se aplica cuando el plugin de formato sandboxed se ejecuta en proceso.

Lo que el sandbox no aplica

Algunas cosas que el sistema de capabilities no cubre y no puede cubrir:

  • Comportamiento dentro de una capability concedida. Un plugin con content:write puede editar cualquier contenido, no solo el suyo. Las capabilities son groseras: dicen «este plugin puede escribir contenido», no «este plugin solo puede escribir el contenido que creó». Un operador debe evaluar el código y el publicador del plugin antes de conceder ese acceso.
  • Bloqueos de edición de entradas. ctx.content.update() y ctx.content.delete() son escrituras programáticas. Un editor que mantiene el bloqueo de edición consultivo de la entrada no las bloquea. Coordina las escrituras del plugin con los editores cuando ambos puedan actualizar la misma entrada.
  • Confianza del operador en Node.js. Cuando el sandbox runner configurado informa no disponible (sin Cloudflare Worker Loader, sin runner del lado de Node instalado, etc.), los plugins de sandboxed: [] se omiten al arrancar. Puedes moverlos a plugins: [] para ejecutarlos en proceso, pero entonces no hay isolate V8, no hay límites de recursos, y el plugin puede llamar a fetch() directamente o leer variables de entorno. Trátalo como confianza a nivel nativo.
  • Canales laterales. El timing, la salida de logs y los datos almacenados son visibles para cualquiera con el acceso adecuado al entorno del host. No uses el sandbox como límite de confidencialidad frente al operador que lo ejecuta.

Consentimiento de capabilities

Cuando un operador instala un plugin sandboxed desde el registro, EmDash muestra un diálogo de consentimiento que enumera las capabilities declaradas. Las actualizaciones que añaden capabilities —por ejemplo, un plugin que antes solo leía contenido y ahora quiere hacer solicitudes de red— aparecen como un diff de capabilities y requieren una nueva aprobación antes de que la nueva versión surta efecto.

Declarar capabilities para un uso futuro posible hace que cada instalación o actualización pida acceso innecesario. Lista lo que usa la versión actual y luego añade una capability en la versión que empieza a usarla.

Validación en tiempo de empaquetado

emdash-plugin bundle y emdash-plugin publish realizan comprobaciones adicionales:

  • Cada capability declarada debe estar en el conjunto reconocido (los errores tipográficos hacen fallar el build).
  • network:request requiere un allowedHosts no vacío; network:request:unrestricted requiere que esté vacío. Consulta Capabilities and hosts.
  • El backend.js empaquetado no puede importar built-ins de Node.js (fs, path, child_process, etc.): los runtimes de sandbox no los proporcionan.

Consulta the manifest reference para los campos de authoring y Bundling and publishing para las comprobaciones de empaquetado.