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.
Execution Modes
Section titled “Execution Modes”| Trusted | Sandboxed | |
|---|---|---|
| Runs in | Main process | Isolated V8 isolate (Dynamic Worker Loader) |
| Capabilities | Advisory (not enforced) | Enforced at runtime |
| Resource limits | None | CPU, memory, subrequests, wall-time |
| Network access | Unrestricted | Blocked; only via ctx.http with host allowlist |
| Data access | Full database access | Scoped to declared capabilities via RPC bridge |
| Available on | All platforms | Cloudflare Workers only |
Trusted Mode
Section titled “Trusted Mode”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:
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. Thecapabilitiesfield 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 Mode (Cloudflare Workers)
Section titled “Sandboxed Mode (Cloudflare Workers)”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:
export default defineConfig({ integrations: [ emdash({ sandboxRunner: "@emdash-cms/cloudflare/sandbox", sandboxed: [ { manifest: seoPluginManifest, code: seoPluginCode, }, ], }), ],});What the Sandbox Enforces
Section titled “What the Sandbox Enforces”-
Capability enforcement
If a plugin declares
capabilities: ["read:content"], it can only callctx.content.get()andctx.content.list(). Attemptingctx.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. -
Resource limits
Every invocation (hook or route call) runs with:
Resource Default Enforced by CPU time 50ms Worker Loader (V8 isolate) Subrequests 10 per invocation Worker Loader (V8 isolate) Wall-clock time 30 seconds EmDash runner ( Promise.race)Memory ~128MB V8 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
SandboxRunnerFactorythat passes different values viaSandboxOptions.limits. Per-site configuration through the EmDash integration config is not yet implemented. -
Network isolation
Sandboxed plugins have
globalOutbound: null— directfetch()calls are blocked at the V8 level. Plugins must usectx.http.fetch(), which proxies through the bridge. The bridge validates the target host against the plugin’sallowedHostslist. -
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.
-
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 bundlecommand warns if a plugin declares these features. - API routes — Custom REST endpoints (
Architecture
Section titled “Architecture”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.
Wrangler Configuration
Section titled “Wrangler Configuration”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" }]}Node.js Deployments
Section titled “Node.js Deployments”When deploying to Node.js (or any non-Cloudflare platform):
- The
NoopSandboxRunneris used. It returnsisAvailable() === false. - Attempting to load sandboxed plugins throws
SandboxNotAvailableError. - All plugins must be registered as trusted plugins in the
pluginsarray. - Capability declarations are purely informational — they are not enforced.
What This Means for Security
Section titled “What This Means for Security”| Threat | Cloudflare (Sandboxed) | Node.js (Trusted only) |
|---|---|---|
| Plugin reads data it shouldn’t | Blocked by bridge capability checks | Not prevented — plugin has full DB access |
| Plugin makes unauthorized network calls | Blocked by globalOutbound: null + host allowlist | Not prevented — plugin can call fetch() directly |
| Plugin exhausts CPU | Isolate aborted by Worker Loader | Not prevented — blocks the event loop |
| Plugin exhausts memory | Isolate terminated by Worker Loader | Not prevented — can crash the process |
| Plugin accesses environment variables | No access (isolated V8 context) | Not prevented — shares process.env |
| Plugin accesses filesystem | No filesystem in Workers | Not prevented — full fs access |
Recommendations for Node.js Deployments
Section titled “Recommendations for Node.js Deployments”- Only install plugins from trusted sources. Review the source code of any plugin before installing. Prefer plugins published by known maintainers.
- 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. - Monitor resource usage. Use process-level monitoring (e.g.,
--max-old-space-size, health checks) to catch runaway plugins. - 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.
Same API, Different Guarantees
Section titled “Same API, Different Guarantees”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 modeexport 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.