Skip to content

Plugin Sandbox

EmDash supports running plugins in two execution modes: trusted and sandboxed. This page explains how each mode works, what protections they provide, and the security implications for different deployment targets.

TrustedSandboxed
Runs inMain processIsolated V8 isolate (Dynamic Worker Loader)
CapabilitiesAdvisory (not enforced)Enforced at runtime
Resource limitsNoneCPU, memory, subrequests, wall-time
Network accessUnrestrictedBlocked; only via ctx.http with host allowlist
Data accessFull database accessScoped to declared capabilities via RPC bridge
Available onAll platformsCloudflare Workers only

Trusted plugins run in the same process as your Astro site. They are loaded from npm packages or local files and configured in astro.config.mjs:

astro.config.mjs
import myPlugin from "@emdash-cms/plugin-analytics";
export default defineConfig({
integrations: [
emdash({
plugins: [myPlugin()],
}),
],
});

In trusted mode:

  • Capabilities are documentation, not enforcement. A plugin declaring ["read:content"] can still access anything in the process. The capabilities field tells administrators what the plugin intends to use.
  • No resource limits. CPU, memory, and network usage are unbounded. A misbehaving plugin can stall the entire request.
  • Full process access. Plugins share the Node.js/Workers runtime with your Astro site. They can import any module, access environment variables, and read/write to the filesystem (on Node.js).

Sandboxed plugins run in isolated V8 isolates provided by Cloudflare’s Dynamic Worker Loader API. Each plugin gets its own isolate with enforced limits.

To enable sandboxing, configure the sandbox runner in your Astro config:

astro.config.mjs
export default defineConfig({
integrations: [
emdash({
sandboxRunner: "@emdash-cms/cloudflare/sandbox",
sandboxed: [
{
manifest: seoPluginManifest,
code: seoPluginCode,
},
],
}),
],
});
  1. Capability enforcement

    If a plugin declares capabilities: ["read:content"], it can only call ctx.content.get() and ctx.content.list(). Attempting ctx.content.create() throws a permission error. This is enforced by the RPC bridge — the plugin cannot bypass it because it has no direct database access.

  2. Resource limits

    Every invocation (hook or route call) runs with:

    ResourceDefaultEnforced by
    CPU time50msWorker Loader (V8 isolate)
    Subrequests10 per invocationWorker Loader (V8 isolate)
    Wall-clock time30 secondsEmDash runner (Promise.race)
    Memory~128MBV8 platform ceiling (not configurable per-plugin)

    Exceeding CPU or subrequest limits causes the Worker Loader to abort the isolate and throw an exception. Exceeding the wall-time limit causes EmDash to reject the invocation promise. Memory is bounded by the V8 platform ceiling but cannot be configured per-plugin.

    These are the built-in defaults. Custom limits can be configured by providing a custom SandboxRunnerFactory that passes different values via SandboxOptions.limits. Per-site configuration through the EmDash integration config is not yet implemented.

  3. Network isolation

    Sandboxed plugins have globalOutbound: null — direct fetch() calls are blocked at the V8 level. Plugins must use ctx.http.fetch(), which proxies through the bridge. The bridge validates the target host against the plugin’s allowedHosts list.

  4. Storage scoping

    All storage operations (KV, collections) are scoped to the plugin’s ID. A plugin cannot read another plugin’s data. Content and media access goes through the bridge, which checks capabilities on every call.

  5. Feature restrictions

    Some features are only available in trusted mode:

    • API routes — Custom REST endpoints (routes) are not available. Sandboxed plugins interact with users through Block Kit admin pages and hooks.
    • Portable Text block types — PT blocks require Astro components for site-side rendering (componentsEntry), loaded at build time from npm. Sandboxed plugins are installed at runtime and cannot ship components.
    • Custom React admin pages — Sandboxed plugins use Block Kit for admin UI instead of shipping React components.

    The emdash plugin bundle command warns if a plugin declares these features.

Sandboxed plugins communicate with EmDash through an RPC bridge:

┌─────────────────────┐ RPC ┌──────────────────────┐
│ Plugin Isolate │ ◄──────────► │ PluginBridge │
│ (Worker Loader) │ (binding) │ (WorkerEntrypoint) │
│ │ │ │
│ ctx.kv.get(k) │──────────────│► kvGet(k) │
│ ctx.content.list() │──────────────│► contentList() │
│ ctx.http.fetch(u) │──────────────│► httpFetch(u) │
└─────────────────────┘ └──────────────────────┘
┌──────────────┐
│ D1 / R2 │
└──────────────┘

The plugin’s code runs in a V8 isolate. It receives a ctx object where every method is a proxy to the bridge. The bridge runs in the main EmDash worker and performs the actual database/storage operations after validating capabilities.

Sandboxing requires Dynamic Worker Loader. Add to your wrangler.jsonc:

{
"worker_loaders": [{ "binding": "LOADER" }],
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "emdash-media" }],
"d1_databases": [{ "binding": "DB", "database_name": "emdash" }]
}

When deploying to Node.js (or any non-Cloudflare platform):

  • The NoopSandboxRunner is used. It returns isAvailable() === false.
  • Attempting to load sandboxed plugins throws SandboxNotAvailableError.
  • All plugins must be registered as trusted plugins in the plugins array.
  • Capability declarations are purely informational — they are not enforced.
ThreatCloudflare (Sandboxed)Node.js (Trusted only)
Plugin reads data it shouldn’tBlocked by bridge capability checksNot prevented — plugin has full DB access
Plugin makes unauthorized network callsBlocked by globalOutbound: null + host allowlistNot prevented — plugin can call fetch() directly
Plugin exhausts CPUIsolate aborted by Worker LoaderNot prevented — blocks the event loop
Plugin exhausts memoryIsolate terminated by Worker LoaderNot prevented — can crash the process
Plugin accesses environment variablesNo access (isolated V8 context)Not prevented — shares process.env
Plugin accesses filesystemNo filesystem in WorkersNot prevented — full fs access
  1. Only install plugins from trusted sources. Review the source code of any plugin before installing. Prefer plugins published by known maintainers.
  2. Use capability declarations as a review checklist. Even though capabilities aren’t enforced, they document the plugin’s intended scope. A plugin declaring ["network:fetch"] that doesn’t need network access is suspicious.
  3. Monitor resource usage. Use process-level monitoring (e.g., --max-old-space-size, health checks) to catch runaway plugins.
  4. Consider Cloudflare for untrusted plugins. If you need to run plugins from unknown sources (e.g., a marketplace), deploy on Cloudflare Workers where sandboxing is available.

A plugin’s code is identical regardless of execution mode. The definePlugin() API, context shape, hooks, routes, and storage all work the same way. What changes is the enforcement:

// This plugin works in both trusted and sandboxed mode
export default definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["read:content", "network:fetch"],
allowedHosts: ["api.analytics.example.com"],
hooks: {
"content:afterSave": async (event, ctx) => {
// In trusted mode: ctx.http is always present (capabilities not enforced)
// In sandboxed mode: ctx.http is present because "network:fetch" is declared
await ctx.http.fetch("https://api.analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ contentId: event.content.id }),
});
},
},
});

The goal is to let plugin authors develop locally in trusted mode (faster iteration, easier debugging) and deploy to sandboxed mode in production without code changes.