Capabilities e segurança

Nesta página

Plugins sandboxed são isolados por padrão. Para fazer qualquer coisa além de ler e gravar seu próprio KV e armazenamento, um plugin precisa declarar uma capability em seu manifesto. O bridge do sandbox regula cada API fornecida pelo host com base nessas declarações — um plugin que não declarou content:read não recebe um ctx.content, e um que não declarou network:request não recebe um ctx.http.

Esta página cobre o que cada capability concede, como o sandbox as aplica e o que não é aplicável.

Declarar capabilities

Capabilities vivem em emdash-plugin.jsonc, junto com slug e o restante do contrato de confiança:

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

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

Declare apenas o que o plugin realmente precisa. O registro mostra essas capabilities aos operadores do site antes da instalação, então cada declaração extra pede que eles aprovem um acesso que o plugin não usa.

Referência de capabilities

CapabilityConcede acesso 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:publishOperações versionadas de publicar, despublicar, agendar e desagendar (implica content:read)
content:restoreLer e restaurar conteúdo na lixeira
comments:readctx.comments.get(), ctx.comments.list(), ctx.comments.count() e dados pessoais de comentários
comments:moderatectx.comments.setStatus() com controle de concorrência de status esperado (implica comments:read)
schema:readctx.schema.listCollections(), ctx.schema.getCollection()
hooks.content-policy:registerHooks de política content:beforePublish, content:beforeSchedule e 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 mídia pronta, com uma resposta bufferizada limitada
media:metadata:writectx.media.updateMetadata() para texto alternativo, legendas e pontos focais
media:writectx.media.getUploadUrl(), ctx.media.upload(), ctx.media.delete() (implica media:read)
network:requestctx.http.fetch() — restrito a allowedHosts
network:request:unrestrictedctx.http.fetch() sem restrição de host (apenas para URLs configuradas pelo usuário)
users:readctx.users.get(), ctx.users.getByEmail(), ctx.users.list()
email:sendctx.email.send() (requer um plugin provedor de e-mail configurado)
hooks.email-transport:registerPermite registrar o hook exclusivo email:deliver (provedores de transporte)
hooks.email-events:registerPermite registrar hooks email:beforeSend / email:afterSend
hooks.page-fragments:registerPermite registrar o hook page:fragments (somente plugins nativos)

As seguintes regras afetam quais capabilities um plugin precisa:

  • Implicações. content:write, content:revisions:read e content:publish implicam automaticamente 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. Você não precisa listar ambas.
  • Autoridades de mídia são separadas. media:read, media:bytes:read e media:metadata:write não se implicam mutuamente. Declare cada operação que o plugin usa. A capability existente media:write continua a implicar media:read por compatibilidade.
  • Taxonomias são separadas do conteúdo. Capabilities de taxonomia não concedem content:read nem content:write. Declare a capability de conteúdo correspondente se o plugin também ler ou editar campos de entrada.
  • A política de publicação é separada do acesso ao conteúdo. hooks.content-policy:register permite a um plugin inspecionar e rejeitar mudanças de estado de publicação por meio de eventos de hook de política. Não fornece ctx.content nem concede ações de edição ou publicação de conteúdo.
  • network:request:unrestricted existe para URLs configuradas pelo usuário. Um plugin de webhook em que o operador digita a URL de destino precisa alcançar hosts que não estão no manifesto. Plugins que sempre chamam APIs conhecidas devem usar network:request + allowedHosts.
  • email:send é regulado pela configuração, não só pela capability. Um plugin pode declarar email:send, mas ctx.email só será preenchido se algum outro plugin tiver registrado um transporte email:deliver.

content:read retorna uma identidade de entrada segura, incluindo o ID do autor, o grupo de tradução, ponteiros de revisão e a versão da linha. Use getTranslations() para descobrir irmãos de locale e getPublicUrl() para resolver uma rota publicada com as regras de locale e barra final do site. getPublicUrl() retorna null para rascunhos, collections não roteáveis, slugs ausentes e locales que o site não serve. Nunca retorna uma URL de preview.

Instantâneos de revisão podem conter valores de campo que um administrador removeu depois. Declare content:revisions:read somente quando o plugin precisar do histórico retido. Resultados de revisão omitem a identidade do autor da revisão.

schema:read expõe definições de collection e campo sem IDs de banco, carimbos de data/hora, metadados de migração ou tipos de coluna SQL. Collections ocultas permanecem visíveis porque hidden controla a navegação de administração, não o acesso a dados.

Criar e traduzir conteúdo

ctx.content.create() aceita um terceiro argumento opcional para o locale da nova entrada:

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

A correspondência de locale não diferencia maiúsculas e armazena a capitalização da configuração de locale do site, de modo que zh-tw se torna zh-TW quando essa é a forma configurada. Um locale explícito malformado sempre lança; quando i18n está configurado, um locale explícito fora da lista configurada também lança. Quando a opção é omitida, o EmDash usa o locale padrão configurado do site; sites sem configuração i18n mantêm o padrão en.

Para adicionar um locale a uma entrada existente, passe seu ID de banco como translationOf:

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

A fonte deve ser uma entrada ativa na mesma collection. A nova entrada entra em seu grupo de tradução, herda seus créditos de byline e atribuições de taxonomia e começa com os valores da fonte para campos marcados como não traduzíveis. Um valor fornecido para um campo não traduzível não substitui o valor da fonte durante a criação da tradução. A validação de conteúdo e os hooks de salvamento passam pelo mesmo caminho de runtime que outras criações de conteúdo. O EmDash não reentra no próprio hook content:afterSave do plugin que cria, e o conteúdo criado de dentro de um hook de salvamento não executa hooks de salvamento novamente.

Cada grupo de tradução pode conter uma entrada ativa por locale. Criar uma segunda entrada para o mesmo grupo e locale lança um erro CONFLICT. Uma fonte ausente lança NOT_FOUND, um locale inválido ou não configurado lança VALIDATION_ERROR, e um hook de salvamento pode interromper a criação com SAVE_REJECTED.

Alterar o estado de publicação

Declare content:publish para publicar, despublicar, agendar ou desagendar uma entrada. Cada ação exige o _rev opaco retornado por getVersioned() ou pela ação anterior. O EmDash roteia esses métodos pelos mesmos hooks de política, promoção de revisão, sincronização de locale, redirecionamentos, atualizações de uso de mídia, invalidação de cache e after-hooks que as ações REST e MCP.

A seguinte rota publica o rascunho atual somente quando a entrada não mudou desde a leitura:

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() aceita { scheduledAt, _rev }; os outros métodos de publicação aceitam { _rev }. Esses métodos não aceitam um override de publishedAt.

Declare content:restore separadamente para ler e restaurar entradas na lixeira. getTrashedVersioned() retorna null para uma entrada ativa ou ausente. Passe seu _rev para restore() para que uma mudança concorrente retorne um conflito em vez de restaurar estado obsoleto.

Criar e atribuir termos de taxonomia

taxonomies:write permite a um plugin criar termos e aplicar deltas de atribuição. Passe IDs de linha de termo ou IDs de grupo de tradução. Slugs de termo não são aceitos porque são delimitados por taxonomia e locale.

O exemplo a seguir cria uma categoria filha e a atribui sem substituir as outras categorias da 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() e removeEntryTerms() são deltas de conjunto idempotentes. Adições concorrentes preservam cada atribuição. O EmDash verifica que a taxonomia está anexada à collection, que a entrada existe e que cada termo pertence à taxonomia nomeada. createTerm() rejeita parentId quando a taxonomia não é hierárquica em vez de ignorá-lo. Criar um termo traduzido com translationOf entra no grupo de tradução do termo fonte; a fonte deve pertencer à mesma taxonomia, e o grupo pode conter apenas um termo por locale.

Criação de definição de taxonomia, anexação a collection, substituição, atualizações de termo e exclusão de termo não estão disponíveis por taxonomies:write.

Ler metadados e bytes de mídia

media:read retorna registros de mídia prontos com dimensões, texto alternativo, legenda, ponto focal, blurhash, cor dominante, ID de pasta e uma URL de asset autenticada baseada em ID. Chamadores autenticados com a permissão media:read podem seguir a URL; solicitações desconectadas são rejeitadas antes de a rota ler o registro de mídia. Os metadados não retornam a chave de armazenamento, a identidade do autor, o hash de conteúdo nem os bytes do arquivo. O hash de conteúdo só está disponível em readBytes() porque pode revelar se o site armazena um arquivo conhecido.

Dentro de um hook ou manipulador de rota, a seguinte chamada lê no máximo 2 MiB de um item de mídia pronto:

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

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

readBytes() bufferiza o resultado. O padrão é 10 MiB quando maxBytes é omitido e rejeita valores acima do máximo do host de 16 MiB. O EmDash conta bytes ao consumir o stream de armazenamento, de modo que um tamanho armazenado incorreto não pode contornar o limite solicitado. Mídia ausente, pendente e com falha é rejeitada sem revelar sua localização de armazenamento.

A seguinte atualização altera o texto de acessibilidade e o ponto focal sem conceder autoridade de upload, substituição ou exclusão:

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

Forneça ambas as coordenadas focais como números de 0 a 1, ou defina ambas como null. Patches concorrentes em campos de metadados diferentes não se substituem.

Enviar mídia

ctx.media.upload() aceita conteúdo de imagem, vídeo, áudio e PDF e lança para qualquer outro tipo de conteúdo. Em um plugin confiável, upload() e getUploadUrl() aplicam a lista de permissões padrão de upload de mídia: imagens PNG, JPEG, GIF, WebP e AVIF, qualquer tipo video/* ou audio/* e application/pdf. Outros tipos lançam um PluginRouteError com status 415, e um tipo de conteúdo malformado lança um com status 400; um manipulador de rota pode deixar qualquer um se propagar como resposta. Em um plugin confiável, um arquivo armazenado por upload() ou reservado por getUploadUrl() também assume uma extensão que corresponde ao tipo de conteúdo, qualquer que seja a extensão do nome do arquivo; quando o tipo de conteúdo não tem extensão conhecida, a extensão do nome do arquivo é mantida somente se pertencer a um tipo de mídia permitido. Um plugin sandboxed mantém a extensão do nome do arquivo quando tem de 1 a 10 letras ou dígitos.

Gerenciar redirecionamentos com segurança

redirects:read fornece listagem de regras paginada por cursor e leituras versionadas de uma única regra. Adicione redirects:write quando o plugin criar, atualizar ou excluir regras. O acesso de escrita pode mudar para onde os visitantes são enviados.

Passe o _rev retornado por get(), create() ou update() inalterado ao atualizar ou excluir uma regra. O EmDash rejeita uma revisão obsoleta para que o plugin possa reler a regra e recalcular sua mudança em vez de sobrescrever trabalho concorrente.

A revisão rastreia a configuração de redirecionamento. A contagem de visitas não torna uma revisão obsoleta.

O exemplo a seguir atualiza um redirecionamento somente se não mudou desde a leitura:

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

Operações de criação validam padrões de caminho, regras terminais 410 e 451, fontes duplicadas, auto-loops e loops de vários saltos com as mesmas regras da API de redirecionamento do EmDash. Atualizações aplicam validação de loop quando a fonte ou o destino muda. Uma atualização somente de habilitação pode reativar um loop preexistente, que a página Redirects relata. O marcador auto pertence a redirecionamentos criados a partir de mudanças de conteúdo do host; a entrada do plugin não pode defini-lo.

Ler e moderar comentários

comments:read concede acesso a comentários não enviados à lixeira. Os resultados incluem o nome e o endereço de e-mail do autor, o corpo do comentário, o hash de IP pseudônimo, o user agent, metadados de moderação, status, IDs de conteúdo de destino e carimbos de data/hora. Excluem o ID da conta de usuário EmDash vinculada. Declare users:read separadamente quando um plugin também precisar procurar contas de usuário.

list() retorna primeiro os comentários mais recentes. Aceita filtros status, collection e contentId, um cursor e um limite de 1 a 100. O limite padrão é 50. count() aceita os mesmos filtros sem paginação.

A seguinte rota aprova um comentário somente se ainda estiver pendente:

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

Se outro moderador alterou o status depois que o plugin o leu, setStatus() rejeita com COMMENT_STATUS_CONFLICT. Leia o comentário novamente e recalcule a decisão antes de tentar de novo. Uma solicitação que se sobrepõe a uma transição anterior antes que seu status seja visível rejeita com COMMENT_MODERATION_IN_PROGRESS; aguarde o fim dessa transição e então leia o comentário atual antes de tentar de novo. Uma transição bem-sucedida executa comment:afterModerate uma vez com origin: { source: "plugin", pluginId }. A aprovação envia a mesma notificação de autor do núcleo que uma aprovação de administrador. Definir um comentário para seu status atual é um no-op e não executa o hook nem envia outra notificação.

Listas de hosts de rede permitidos

Plugins com network:request só podem buscar hosts listados em allowedHosts. Um *. inicial corresponde tanto ao domínio nomeado quanto a seus subdomínios:

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

O bridge verifica o host da URL da solicitação contra a lista de permissões antes de encaminhar a solicitação. Uma solicitação a um host que não foi declarado lança dentro do plugin sem nunca sair do sandbox.

network:request:unrestricted ignora a lista de hosts do manifesto. O bridge do sandbox ainda aceita apenas HTTP e HTTPS, bloqueia hosts internos conhecidos e endereços literais privados, reverifica cada redirecionamento e remove cabeçalhos de credenciais quando um redirecionamento cruza origens. Use acesso irrestrito somente quando um operador fornecer o destino em runtime. Para destinos fixos, declare network:request com hosts explícitos para que o diálogo de consentimento os nomeie.

ctx.http.fetch() bufferiza corpos de solicitação e resposta e limita cada corpo decodificado a 8 MiB. A Response WHATWG retornada preserva bytes binários, texto de status, cabeçalhos, URL final, estado de redirecionamento e o comportamento de clone() em ambos os runners de sandbox. Leia dados binários com arrayBuffer() ou blob().

O que o sandbox aplica

Quando um sandbox runner está ativo, o runtime aplica:

  1. Regulação por capability. A fábrica PluginContext só preenche ctx.content, ctx.comments, ctx.schema, ctx.taxonomies, ctx.redirects, ctx.media, ctx.http, ctx.users, ctx.email quando a capability correspondente é declarada. Chamar um método em uma capability não declarada não é possível — não há objeto ali.

  2. Escopo de armazenamento e KV. Toda operação de armazenamento e KV é delimitada ao ID do plugin do runtime. Um plugin não pode ler o KV ou as collections de armazenamento de outro plugin e só pode acessar collections declaradas em seu manifesto.

  3. Isolamento de rede. O fetch() direto e outros primitivos de rede são bloqueados pelo runner. A única forma de alcançar a rede é ctx.http.fetch(), que passa pela validação de host do bridge.

  4. Sem bindings do host. Plugins sandboxed não veem variáveis de ambiente, o sistema de arquivos nem bindings de plataforma — mesmo que seu worker host os tenha. O runtime do plugin é um isolate limpo apenas com o bridge e as capabilities declaradas.

  5. Limites de recursos. O runner Cloudflare usa por padrão 50 ms de CPU, 10 subrequests e 30 segundos de tempo de parede por invocação. O Worker Loader aplica CPU e subrequests; o runner aplica o tempo de parede. O Worker Loader tem um teto de memória da plataforma, mas sua opção memoryMb por plugin não é atualmente aplicável. O runner workerd do Node.js aplica apenas o padrão de 30 segundos de tempo de parede; ele avisa quando um site configura limites de CPU, memória ou subrequest que o workerd independente não pode aplicar. Um timeout por hook só se aplica quando o plugin no formato sandboxed roda em processo.

O que o sandbox não aplica

Algumas coisas que o sistema de capabilities não cobre e não pode cobrir:

  • Comportamento dentro de uma capability concedida. Um plugin com content:write pode editar qualquer conteúdo, não só o seu. Capabilities são grosseiras — dizem «este plugin pode escrever conteúdo», não «este plugin só pode escrever o conteúdo que criou». Um operador deve avaliar o código e o publicador do plugin antes de conceder esse acesso.
  • Bloqueios de edição de entrada. ctx.content.update() e ctx.content.delete() são escritas programáticas. Um editor que mantém o bloqueio de edição consultivo da entrada não as bloqueia. Coordene as escritas do plugin com os editores quando ambos puderem atualizar a mesma entrada.
  • Confiança do operador no Node.js. Quando o sandbox runner configurado informa indisponível (sem Cloudflare Worker Loader, sem runner do lado do Node instalado, etc.), plugins de sandboxed: [] são ignorados na inicialização. Você pode movê-los para plugins: [] para executá-los em processo — mas então não há isolate V8, não há limites de recursos, e o plugin pode chamar fetch() diretamente ou ler variáveis de ambiente. Trate isso como confiança de nível nativo.
  • Canais laterais. Timing, saída de log e dados armazenados são visíveis para quem tiver o acesso apropriado ao ambiente do host. Não use o sandbox como limite de confidencialidade contra o operador que o executa.

Consentimento de capabilities

Quando um operador instala um plugin sandboxed a partir do registro, o EmDash mostra um diálogo de consentimento listando as capabilities declaradas. Atualizações que adicionam capabilities — por exemplo, um plugin que antes só lia conteúdo e agora quer fazer solicitações de rede — aparecem como um diff de capabilities e exigem nova aprovação antes que a nova versão entre em vigor.

Declarar capabilities para possível uso futuro faz com que cada instalação ou atualização peça acesso desnecessário. Liste o que a versão atual usa e então adicione uma capability na versão que começa a usá-la.

Validação no momento do bundle

emdash-plugin bundle e emdash-plugin publish realizam verificações adicionais:

  • Toda capability declarada deve estar no conjunto reconhecido (erros de digitação fazem o build falhar).
  • network:request exige um allowedHosts não vazio; network:request:unrestricted exige que esteja vazio. Veja Capabilities and hosts.
  • O backend.js empacotado não pode importar built-ins do Node.js (fs, path, child_process, etc.) — runtimes de sandbox não os fornecem.

Veja the manifest reference para os campos de authoring e Bundling and publishing para as verificações de bundle.