Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workerd-capability-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/sandbox-workerd": patch
---

Fixes sandboxed plugins being denied on the workerd runner when their manifest declares current capability names such as `content:read`, `media:write`, `users:read` or `network:request`. Manifests using older capability aliases keep working, and permission errors now name the current capability.
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ jobs:
# Render tests use the Astro Vite plugin (vitest.repro.config.ts);
# they can't run under the plain-node config in test:unit.
- run: pnpm --filter emdash exec vitest run --config vitest.repro.config.ts
# Sandbox capability enforcement lives in the workerd package, which
# test:unit does not cover. Only the files that need no workerd or
# Miniflare startup run here.
- run: >-
pnpm --filter @emdash-cms/sandbox-workerd exec vitest run
test/bridge-capability-aliases.test.ts
test/wrapper-marshal.test.ts
test/plugin-integration.test.ts

test-smoke:
name: Smoke Tests
Expand Down
62 changes: 38 additions & 24 deletions packages/workerd/src/sandbox/bridge-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
createHttpAccess,
createSandboxRouteErrorEnvelope,
createUnrestrictedHttpAccess,
normalizeCapabilities,
PluginStorageRepository,
resolveContentCreateLocale,
} from "emdash";
Expand Down Expand Up @@ -122,6 +123,13 @@ export interface BridgeHandlerOptions {
export function createBridgeHandler(
opts: BridgeHandlerOptions,
): (request: Request) => Promise<Response> {
// Capability arrays may contain legacy aliases from older manifests;
// everything below compares against current names only.
const resolved: BridgeHandlerOptions = {
...opts,
capabilities: normalizeCapabilities(opts.capabilities),
};

return async (request: Request): Promise<Response> => {
try {
const url = new URL(request.url);
Expand All @@ -139,7 +147,7 @@ export function createBridgeHandler(
}
}

const result = await dispatch(opts, method, body);
const result = await dispatch(resolved, method, body);
return Response.json({ result });
} catch (error) {
const sandboxRouteError = createSandboxRouteErrorEnvelope(error);
Expand Down Expand Up @@ -180,13 +188,13 @@ async function dispatch(

// ── Content ─────────────────────────────────────────────────────
case "content/get":
requireCapability(opts, "read:content");
requireCapability(opts, "content:read");
return contentGet(db, requireString(body, "collection"), requireString(body, "id"));
case "content/list":
requireCapability(opts, "read:content");
requireCapability(opts, "content:read");
return contentList(db, requireString(body, "collection"), body);
case "content/create":
requireCapability(opts, "write:content");
requireCapability(opts, "content:write");
const createOptions = optionalRecord(body, "options");
const locale = resolveContentCreateLocale(
createOptions ? optionalString(createOptions, "locale") : undefined,
Expand All @@ -200,7 +208,7 @@ async function dispatch(
locale,
);
case "content/update":
requireCapability(opts, "write:content");
requireCapability(opts, "content:write");
await opts.beforeContentWrite?.();
return contentUpdate(
db,
Expand All @@ -209,11 +217,11 @@ async function dispatch(
requireRecord(body, "data"),
);
case "content/delete":
requireCapability(opts, "write:content");
requireCapability(opts, "content:write");
await opts.beforeContentWrite?.();
return contentDelete(db, requireString(body, "collection"), requireString(body, "id"));
case "content/createMany":
requireCapability(opts, "write:content");
requireCapability(opts, "content:write");
const createManyLocale = resolveContentCreateLocale(undefined, opts.i18nConfig ?? null);
await opts.beforeContentWrite?.();
return contentCreateMany(
Expand All @@ -223,15 +231,15 @@ async function dispatch(
createManyLocale,
);
case "content/updateMany":
requireCapability(opts, "write:content");
requireCapability(opts, "content:write");
await opts.beforeContentWrite?.();
return contentUpdateMany(
db,
requireString(body, "collection"),
requireUpdateManyItems(body, "items"),
);
case "content/deleteMany":
requireCapability(opts, "write:content");
requireCapability(opts, "content:write");
await opts.beforeContentWrite?.();
return contentDeleteMany(
db,
Expand Down Expand Up @@ -260,13 +268,13 @@ async function dispatch(

// ── Media ───────────────────────────────────────────────────────
case "media/get":
requireCapability(opts, "read:media");
requireCapability(opts, "media:read");
return mediaGet(db, requireString(body, "id"));
case "media/list":
requireCapability(opts, "read:media");
requireCapability(opts, "media:read");
return mediaList(db, body);
case "media/upload":
requireCapability(opts, "write:media");
requireCapability(opts, "media:write");
return mediaUpload(
db,
requireString(body, "filename"),
Expand All @@ -276,12 +284,12 @@ async function dispatch(
opts.storage,
);
case "media/delete":
requireCapability(opts, "write:media");
requireCapability(opts, "media:write");
return mediaDelete(db, requireString(body, "id"), opts.storage);

// ── HTTP ────────────────────────────────────────────────────────
case "http/fetch":
requireCapability(opts, "network:fetch");
requireCapability(opts, "network:request");
return httpFetch(requireString(body, "url"), body.init, opts);

// ── Email ───────────────────────────────────────────────────────
Expand All @@ -296,13 +304,13 @@ async function dispatch(

// ── Users ───────────────────────────────────────────────────────
case "users/get":
requireCapability(opts, "read:users");
requireCapability(opts, "users:read");
return userGet(db, requireString(body, "id"));
case "users/getByEmail":
requireCapability(opts, "read:users");
requireCapability(opts, "users:read");
return userGetByEmail(db, requireString(body, "email"));
case "users/list":
requireCapability(opts, "read:users");
requireCapability(opts, "users:read");
return userList(db, body);

// ── Storage (document store, scoped to declared collections) ────
Expand Down Expand Up @@ -540,18 +548,24 @@ function requireOrderBy(
function requireCapability(opts: BridgeHandlerOptions, capability: string): void {
// Strict capability check matching the Cloudflare PluginBridge.
// We do NOT imply write → read here: a plugin that declares only
// write:content cannot call ctx.content.get/list. The plugin must
// declare read:content explicitly. This matches the Cloudflare bridge
// content:write cannot call ctx.content.get/list. The plugin must
// declare content:read explicitly. This matches the Cloudflare bridge
// behavior and ensures sandboxed plugins behave the same on both runners.
//
// Note: the in-process PluginContextFactory in core does build the read
// API onto the write object, so a trusted plugin can read with only
// write:content. The sandbox bridges are stricter on purpose — they
// content:write. The sandbox bridges are stricter on purpose — they
// enforce the manifest as written.
//
// The one exception: network:fetch:any is documented as a strict
// superset of network:fetch, so the broader capability satisfies it.
if (capability === "network:fetch" && opts.capabilities.includes("network:fetch:any")) return;
// The one exception: network:request:unrestricted is documented as a
// strict superset of network:request, so the broader capability
// satisfies it.
if (
capability === "network:request" &&
opts.capabilities.includes("network:request:unrestricted")
) {
return;
}
if (!opts.capabilities.includes(capability)) {
// Error message matches Cloudflare PluginBridge format
throw new Error(`Missing capability: ${capability}`);
Expand Down Expand Up @@ -1424,7 +1438,7 @@ async function httpFetch(
headers: Record<string, string>;
bodyBase64: string;
}> {
const hasAnyFetch = opts.capabilities.includes("network:fetch:any");
const hasAnyFetch = opts.capabilities.includes("network:request:unrestricted");
const httpAccess = hasAnyFetch
? createUnrestrictedHttpAccess(opts.pluginId)
: createHttpAccess(opts.pluginId, opts.allowedHosts || []);
Expand Down
2 changes: 1 addition & 1 deletion packages/workerd/src/sandbox/capnp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function generateCapnpConfig(options: CapnpOptions): string {
//
// In other words: plugins cannot reach the internet by calling plain
// fetch(). They must use ctx.http.fetch(), which goes through the
// http/fetch bridge handler, which enforces network:fetch capability
// http/fetch bridge handler, which enforces network:request capability
// and the allowedHosts allowlist.
lines.push(` globalOutbound = "emdash-backing",`);
// Note: workerd capnp config does not support per-worker cpu/memory
Expand Down
6 changes: 3 additions & 3 deletions packages/workerd/src/sandbox/dev-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export class MiniflareDevRunner implements SandboxRunner {

// outboundService intercepts all fetch() calls from this worker.
// Calls to http://bridge/... go to the Node bridge handler.
// Other calls pass through for network:fetch.
// Other calls pass through for network:request.
workerConfigs.push({
name: pluginId.replace(SAFE_ID_RE, "_"),
// The wrapper imports "sandbox-plugin.js", so we provide both
Expand All @@ -207,13 +207,13 @@ export class MiniflareDevRunner implements SandboxRunner {
// Only allow bridge calls. Any other outbound fetch is blocked
// to enforce that all network access goes through ctx.http.fetch
// (which routes via the bridge with capability + host validation).
// Without this, plugins could bypass network:fetch / allowedHosts
// Without this, plugins could bypass network:request / allowedHosts
// by calling plain fetch() directly.
if (url.hostname === "bridge") {
return bridgeHandler(request);
}
return new Response(
`Direct fetch() blocked in sandbox. Plugin "${manifest.id}" must use ctx.http.fetch() (requires network:fetch capability).`,
`Direct fetch() blocked in sandbox. Plugin "${manifest.id}" must use ctx.http.fetch() (requires network:request capability).`,
{ status: 403 },
);
},
Expand Down
8 changes: 5 additions & 3 deletions packages/workerd/src/sandbox/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - Exposes an HTTP fetch handler for hook/route invocation
*/

import type { PluginManifest } from "emdash";
import { normalizeCapabilities, type PluginManifest } from "emdash";

const TRAILING_SLASH_RE = /\/$/;
const NEWLINE_RE = /[\n\r]/g;
Expand All @@ -38,8 +38,10 @@ export interface WrapperOptions {

export function generatePluginWrapper(manifest: PluginManifest, options: WrapperOptions): string {
const site = options.site ?? { name: "", url: "", locale: "en" };
const hasReadUsers = manifest.capabilities.includes("read:users");
const hasEmailSend = manifest.capabilities.includes("email:send");
// Manifests written before the capability rename carry legacy names.
const capabilities = normalizeCapabilities(manifest.capabilities ?? []);
const hasReadUsers = capabilities.includes("users:read");
const hasEmailSend = capabilities.includes("email:send");

return `
// =============================================================================
Expand Down
Loading
Loading