Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/bumpy-crabs-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"emdash": minor
"@emdash-cms/cloudflare": patch
"@emdash-cms/workerd": minor
---

Adds workerd-based plugin sandboxing for Node.js deployments.

- **emdash**: Adds `isHealthy()` to `SandboxRunner` interface, `SandboxUnavailableError` class, `sandbox: false` config option, `mediaStorage` field on `SandboxOptions`, and exports `createHttpAccess`/`createUnrestrictedHttpAccess`/`PluginStorageRepository`/`UserRepository`/`OptionsRepository` for platform adapters.
- **@emdash-cms/cloudflare**: Implements `isHealthy()` on `CloudflareSandboxRunner`. Fixes `storageQuery()` and `storageCount()` to honor `where`, `orderBy`, and `cursor` options (previously ignored, causing infinite pagination loops and incorrect filtered counts). Adds `storageConfig` to `PluginBridgeProps` so `PluginStorageRepository` can use declared indexes.
- **@emdash-cms/workerd**: New package. `WorkerdSandboxRunner` for production (workerd child process + capnp config + authenticated HTTP backing service) and `MiniflareDevRunner` for development.
57 changes: 57 additions & 0 deletions docs/src/content/docs/plugins/creating-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,63 @@ Test plugins by creating a minimal Astro site with the plugin registered:

For unit tests, mock the `PluginContext` interface and call hook handlers directly.

### Testing in the Sandbox

If your plugin will run sandboxed (marketplace distribution or on sites with workerd enabled), test it under sandbox conditions locally to catch capability violations before deploying.

<Steps>

1. Install the workerd sandbox runner in your test site:

```bash
npm install @emdash-cms/workerd
```

2. Enable it in your test site's config:

```typescript title="astro.config.mjs"
export default defineConfig({
integrations: [
emdash({
sandboxRunner: "@emdash-cms/workerd/sandbox",
plugins: [myPlugin()],
}),
],
});
```

3. Run the dev server and exercise your plugin's hooks and routes.

</Steps>

If something works in trusted mode but fails in the sandbox, use `sandbox: false` to confirm it's a sandbox issue:

```typescript title="astro.config.mjs"
emdash({
sandboxRunner: "@emdash-cms/workerd/sandbox",
sandbox: false, // Temporarily bypass sandbox for debugging
plugins: [myPlugin()],
})
```

### What Behaves Differently in the Sandbox

Your plugin code is the same in both modes, but the sandbox enforces restrictions that trusted mode does not:

| What | Trusted mode | Sandboxed mode |
|---|---|---|
| **Undeclared capabilities** | `ctx.content`, `ctx.media`, etc. are always present | Present on `ctx`, but methods throw capability errors when called |
| **Network access** | `fetch()` works globally | Only via `ctx.http.fetch()`, restricted to `allowedHosts` |
| **Node.js builtins** | `fs`, `path`, `child_process` available | Not available (V8 isolate, no Node APIs) |
| **Environment variables** | `process.env` accessible | Not accessible |
| **CPU time** | Unbounded | Limited (default 50ms per invocation) |
| **Wall-clock time** | Unbounded | Limited (default 30s per invocation) |
| **Direct DB access** | Possible (but discouraged) | Not possible, all access via `ctx.*` |

<Aside type="tip">
The easiest way to ensure sandbox compatibility: only use the `ctx` object passed to your hooks and routes. If your plugin only touches `ctx.content`, `ctx.storage`, `ctx.kv`, `ctx.http`, `ctx.email`, and `ctx.log`, it will work identically in both modes.
</Aside>

## Portable Text Block Types

Plugins can add custom block types to the Portable Text editor. These appear in the editor's slash command menu and can be inserted into any `portableText` field.
Expand Down
107 changes: 87 additions & 20 deletions docs/src/content/docs/plugins/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ EmDash supports running plugins in two execution modes: **trusted** and **sandbo
| **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 |
| **Available on** | All platforms | Cloudflare Workers, Node.js (with workerd) |

## Trusted Mode

Expand Down Expand Up @@ -145,34 +145,99 @@ Sandboxing requires Dynamic Worker Loader. Add to your `wrangler.jsonc`:

## 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>
Node.js supports plugin sandboxing via [workerd](https://github.com/cloudflare/workerd), the open-source runtime that powers Cloudflare Workers. When configured, plugins run in isolated V8 isolates with the same capability enforcement as on Cloudflare.

### Enabling Sandboxing on Node.js

<Steps>

1. Install the workerd sandbox runner:

```bash
npm install @emdash-cms/workerd
```

2. Configure it in your Astro config:

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

3. Restart your dev server. Sandboxed plugins will now run in workerd isolates.

</Steps>

In development, if [miniflare](https://miniflare.dev/) is installed, the runner uses it for faster startup. In production (`NODE_ENV=production`), it spawns workerd as a child process with a generated configuration. Install miniflare as a dev dependency for the best local development experience:

```bash
npm install -D miniflare
```

### Debugging Escape Hatch

When deploying to Node.js (or any non-Cloudflare platform):
If you need to determine whether a bug is in your plugin code or in the sandbox, disable sandboxing temporarily:

```typescript title="astro.config.mjs"
emdash({
sandboxRunner: "@emdash-cms/workerd/sandbox",
sandbox: false, // Disable sandboxing, all plugins run in-process
})
```

When `sandbox: false` is set:

- Build-time sandboxed plugins (registered via `sandboxed: [...]` in your config) load in-process and run their hooks and routes normally. Plugin state (active/inactive) from the admin UI is respected.
- Marketplace plugins also load in-process and run their hooks and routes. Cold-start loads them before the hook pipeline is built; runtime install/update/uninstall via the admin UI rebuilds the pipeline so changes take effect immediately without a server restart.
- All plugin code runs with full Node.js privileges. Capability declarations are not enforced at the runtime level. Use this only for debugging — re-enable sandboxing for normal operation.

### Without workerd

If workerd is not installed, EmDash falls back to trusted mode for all plugins. A warning is logged at startup:

> Plugin sandbox is configured but not available on this platform. Sandboxed plugins will not be loaded. If using @emdash-cms/workerd/sandbox, ensure workerd is installed.

In this mode:

- 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.
- Capability declarations are purely informational.

### What This Means for Security
### Security Comparison

| 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 |
| Threat | Cloudflare (Sandboxed) | Node.js + workerd (Sandboxed) | Node.js (Trusted only) |
|---|---|---|---|
| Plugin reads unauthorized data | Blocked by bridge | Blocked by bridge | **Not prevented** |
| Plugin makes unauthorized network calls | Blocked by host allowlist | Blocked by host allowlist | **Not prevented** |
| Plugin exhausts CPU | Isolate aborted (per-request CPU limit) | Wall-time only (no per-request CPU limit) | **Not prevented** |
| Plugin exhausts memory | 128MB per-isolate limit | **Not enforced by standalone workerd** | **Not prevented** |
| Plugin makes excessive subrequests | Subrequest limit enforced | **Not enforced by standalone workerd** | **Not prevented** |
| Plugin runs forever (wall-clock) | Wall-time limit | Wall-time limit (Promise.race wrapper) | **Not prevented** |
| Plugin accesses env vars | No access (isolated V8) | No access (isolated V8) | **Not prevented** |
| Plugin accesses filesystem | No filesystem in Workers | No filesystem in workerd | **Not prevented** |
| Defense against V8 zero-days | Rapid patching + kernel hardening | Dependent on workerd release cycle | N/A |

<Aside type="caution" title="Resource limits on the workerd Node path">
Standalone workerd does **not** enforce per-worker `cpuMs`, `memoryMb`, or `subrequests` limits. Those are Cloudflare platform features, not workerd capnp options. The only resource limit enforced on the Node path is `wallTimeMs`, applied via `Promise.race` in the runner.

A misbehaving plugin can still consume arbitrary CPU and memory until it hits the wall-clock timeout. For full per-request resource isolation, deploy on Cloudflare Workers.

The plugin code is still isolated (V8 isolate boundaries, no filesystem, no env vars, capability-gated APIs) — only the resource limit enforcement is weaker.
</Aside>

### 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.
1. **Install workerd for sandboxing.** It provides the same V8 isolate boundaries and capability enforcement as Cloudflare with no code changes to your plugins.
2. **Set NODE_ENV=production explicitly.** The runner uses the production hardening path (child process supervision, crash restart with backoff, wall-time wrapper) when NODE_ENV is "production". Other values fall back to the dev runner if miniflare is installed.
3. **Use capability declarations as a review checklist.** Even in trusted mode, they document the plugin's intended scope.
4. **Monitor resource usage.** Since CPU/memory limits are not enforced per worker, use process-level monitoring (`--max-old-space-size`, container memory limits, OS cgroups) as the primary defense.
5. **Pin workerd versions.** The workerd binary is pinned via npm. Pin the version to avoid unexpected API changes.
6. **For hostile multi-tenant plugins, deploy on Cloudflare.** The standalone workerd path is appropriate for trusted or semi-trusted plugins. Hostile multi-tenant scenarios need Cloudflare's per-request CPU/memory enforcement.

## Same API, Different Guarantees

Expand All @@ -199,3 +264,5 @@ export default definePlugin({
```

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.

With workerd installed locally, you can also test under sandbox conditions during development. See [Testing in the Sandbox](/plugins/creating-plugins/#testing-in-the-sandbox) for setup instructions.
80 changes: 55 additions & 25 deletions packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
*
*/

import type { D1Database } from "@cloudflare/workers-types";
import { WorkerEntrypoint } from "cloudflare:workers";
import type { SandboxEmailSendCallback } from "emdash";
import { ulid } from "emdash";
import { ulid, PluginStorageRepository } from "emdash";
import { Kysely } from "kysely";
import { D1Dialect } from "kysely-d1";

/** Regex to validate collection names (prevent SQL injection) */
const COLLECTION_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
Expand Down Expand Up @@ -125,6 +128,11 @@ export interface PluginBridgeProps {
capabilities: string[];
allowedHosts: string[];
storageCollections: string[];
/** Per-collection storage config (matches manifest.storage entries) */
storageConfig?: Record<
string,
{ indexes?: Array<string | string[]>; uniqueIndexes?: Array<string | string[]> }
>;
}

/**
Expand All @@ -139,6 +147,28 @@ export interface PluginBridgeProps {
* 3. Plugins call bridge methods which validate and proxy to the database
*/
export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridgeProps> {
/**
* Construct a PluginStorageRepository for the requested collection.
* Uses the indexes from the plugin's storage config (if provided) so
* query/count operations support WHERE/ORDER BY/cursor pagination
* matching in-process and workerd sandbox plugins.
*/
private getStorageRepo(collection: string): PluginStorageRepository {
const { pluginId, storageConfig } = this.ctx.props;
const config = storageConfig?.[collection];
// Merge unique indexes into the indexes list since both are queryable
const allIndexes: Array<string | string[]> = [
...(config?.indexes ?? []),
...(config?.uniqueIndexes ?? []),
];
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- D1 is the kysely-d1 dialect database type
const db = new Kysely<unknown>({
dialect: new D1Dialect({ database: this.env.DB as D1Database }),
});
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely<unknown> is compatible with PluginStorageRepository's expected db
return new PluginStorageRepository(db as never, pluginId, collection, allIndexes);
}

// =========================================================================
// KV Operations - scoped to plugin namespace
// =========================================================================
Expand Down Expand Up @@ -240,45 +270,45 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge

async storageQuery(
collection: string,
opts: { limit?: number; cursor?: string } = {},
opts: {
limit?: number;
cursor?: string;
where?: Record<string, unknown>;
orderBy?: Record<string, "asc" | "desc">;
} = {},
): Promise<{
items: Array<{ id: string; data: unknown }>;
hasMore: boolean;
cursor?: string;
}> {
const { pluginId, storageCollections } = this.ctx.props;
const { storageCollections } = this.ctx.props;
if (!storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
const limit = Math.min(opts.limit ?? 50, 1000);
const results = await this.env.DB.prepare(
"SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? LIMIT ?",
)
.bind(pluginId, collection, limit + 1)
.all<{ id: string; data: string }>();

const items = (results.results ?? []).slice(0, limit).map((row) => ({
id: row.id,
data: JSON.parse(row.data),
}));
// Delegate to PluginStorageRepository for proper WHERE/ORDER BY/cursor support
const repo = this.getStorageRepo(collection);
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record<string, unknown>
const result = await repo.query({
where: opts.where as never,
orderBy: opts.orderBy,
limit: opts.limit,
cursor: opts.cursor,
});
return {
items,
hasMore: (results.results ?? []).length > limit,
cursor: items.length > 0 ? items.at(-1)!.id : undefined,
items: result.items,
hasMore: result.hasMore,
cursor: result.cursor,
};
}

async storageCount(collection: string): Promise<number> {
const { pluginId, storageCollections } = this.ctx.props;
async storageCount(collection: string, where?: Record<string, unknown>): Promise<number> {
const { storageCollections } = this.ctx.props;
if (!storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
const result = await this.env.DB.prepare(
"SELECT COUNT(*) as count FROM _plugin_storage WHERE plugin_id = ? AND collection = ?",
)
.bind(pluginId, collection)
.first<{ count: number }>();
return result?.count ?? 0;
const repo = this.getStorageRepo(collection);
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record<string, unknown>
return repo.count(where as never);
}

async storageGetMany(collection: string, ids: string[]): Promise<Map<string, unknown>> {
Expand Down
20 changes: 20 additions & 0 deletions packages/cloudflare/src/sandbox/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export interface PluginBridgeProps {
capabilities: string[];
allowedHosts: string[];
storageCollections: string[];
storageConfig?: Record<
string,
{ indexes?: Array<string | string[]>; uniqueIndexes?: Array<string | string[]> }
>;
}

/**
Expand Down Expand Up @@ -124,6 +128,13 @@ export class CloudflareSandboxRunner implements SandboxRunner {
return !!getLoader() && !!getPluginBridge();
}

/**
* Worker Loader runs in-process, always healthy if available.
*/
isHealthy(): boolean {
return this.isAvailable();
}

/**
* Load a sandboxed plugin.
*
Expand Down Expand Up @@ -236,6 +247,15 @@ class CloudflareSandboxedPlugin implements SandboxedPlugin {
capabilities: this.manifest.capabilities || [],
allowedHosts: this.manifest.allowedHosts || [],
storageCollections: Object.keys(this.manifest.storage || {}),
storageConfig: this.manifest.storage as
| Record<
string,
{
indexes?: Array<string | string[]>;
uniqueIndexes?: Array<string | string[]>;
}
>
| undefined,
},
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@
"@apidevtools/swagger-parser": "^12.1.0",
"@arethetypeswrong/cli": "catalog:",
"@emdash-cms/blocks": "workspace:*",
"@types/better-sqlite3": "^7.6.12",
"@types/better-sqlite3": "catalog:",
"@types/pg": "^8.16.0",
"@types/sanitize-html": "^2.16.0",
"@types/sax": "^1.2.7",
Expand Down
Loading
Loading