---
title: Plugin Sandbox
description: How EmDash isolates untrusted plugins on Cloudflare Workers vs Node.js deployments.
---

import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";

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

| | 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

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`:

```typescript title="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).

<Aside type="caution" title="Security implication">
	Only install trusted plugins from sources you trust — npm packages you've reviewed, or code you've written yourself. In trusted mode, a malicious plugin has the same access as your application code.
</Aside>

## Sandboxed Mode (Cloudflare Workers)

Sandboxed plugins run in isolated V8 isolates provided by Cloudflare's [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) API. Each plugin gets its own isolate with enforced limits.

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

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

### What the Sandbox Enforces

<Steps>

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:

   | 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 `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.

</Steps>

### 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

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

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

## Node.js Deployments

<Aside type="danger" title="No isolation on Node.js">
	Node.js does not support plugin sandboxing. All plugins run in trusted mode regardless of configuration. There is no V8 isolate boundary, no resource limits, and no capability enforcement at the runtime level.
</Aside>

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.

### 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

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.

## 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**:

```typescript
// 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.
