How Cloudflare OS Sandboxes Gadget Applications: Inside the Double Sandbox Architecture

Cloudflare OS isolates each Gadget application in a double sandbox that combines a restricted client-side iframe with a server-side Durable Object, enforcing least-privilege access through Cap'n Web RPC and explicit capability bindings.

The cloudflare/cloudflare-os repository implements a robust security model for user-provided Gadget applications. By layering browser-based isolation atop server-side worker constraints, Cloudflare OS ensures Gadget code cannot access the network, external services, or other users' data without explicit administrative permission. This architecture protects both the end-user's browser environment and the server infrastructure through a defense-in-depth approach.

The Double Sandbox Architecture

Cloudflare OS implements a double sandbox that separates concerns between the client presentation layer and server execution environment. Each layer operates under distinct restrictions that collectively prevent unauthorized data exfiltration or cross-contamination.

Client-Side Iframe Isolation

The Gadget UI runs inside a sandboxed <iframe> that has no direct network access. In packages/workshop-frontend/src/gadgetLoader.ts, the iframe is created with strict sandbox attributes:

const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';               // no "allow-same-origin", no network
iframe.srcdoc = gadgetHtml;                     // HTML generated by the backend
iframe.allow = '';                              // no extra permissions
document.body.append(iframe);

The backend enforces additional restrictions via Content-Security-Policy headers in packages/workshop-backend/src/browser-export.ts. The iframe receives:


Content-Security-Policy: connect-src 'none'; sandbox allow-scripts;

This CSP directive disables fetch(), alert(), confirm(), and other privileged APIs, ensuring the Gadget can only communicate with the Workshop kernel via postMessage() and MessagePort through a Cap'n Web RPC session.

Server-Side Durable Object Confinement

Gadget code executes in a dedicated Durable Object defined in packages/workshop-backend/src/agent.ts. This lightweight Cloudflare Worker variant runs in a "restricted and heavily-sandboxed variant of Cloudflare Workers" where fetch() calls are blocked by default.

The Durable Object class only accesses bindings explicitly provisioned by the Workshop author through this.env. For example, if a Gadget requires KV storage, the admin must explicitly bind a KV namespace to the Durable Object's environment. The server-side implementation validates all actions against these permitted bindings:

// packages/workshop-backend/src/agent.ts
export class GadgetDO implements GadgetApi {
  async invoke(action: string, args: unknown[]) {
    // Only allowed actions are exposed via bindings.
    if (action === 'fetchUrl' && this.env.ALLOWED_FETCH) {
      const [url] = args as [string];
      return await this.env.ALLOWED_FETCH.fetch(url);
    }
    throw new Error('Action not permitted');
  }
}

Capability-Based RPC Communication

All interactions between the sandboxed iframe and the Durable Object occur through typed RPC calls defined in packages/workshop-shared/src/api.ts. The Cap'n Web RPC system validates arguments and returns at runtime using the @validateRpc() decorator.

The RPC interface exports a GadgetApi that the iframe accesses through an injected gadget stub:

// packages/workshop-shared/src/api.ts
export interface GadgetApi {
  /** Read a value from the Gadget's storage. */
  read(key: string): Promise<string | undefined>;

  /** Write a value to the Gadget's storage. */
  write(key: string, value: string): Promise<void>;

  /** Perform an action that may need a binding (e.g., KV, external API). */
  invoke(action: string, args: unknown[]): Promise<unknown>;
}

Promise pipelining allows the stub to forward method calls to the server without awaiting, maintaining performance while preserving security boundaries. The server exposes capabilities back to the iframe—such as UI capabilities or storage capabilities—only when explicitly granted by the Workshop configuration.

Per-User Isolation and Security Guarantees

Each user receives a private Durable Object instance for every Gadget they instantiate. The kernel creates these objects in packages/workshop-backend/src/agent.ts using a key derived from the concatenation of the user ID and gadget ID. This guarantees separate storage and execution contexts, preventing cross-gadget interference.

The gatekeeper UI described in packages/workshop-shared/src/gatekeeper.ts provides an additional sandboxing layer for administrative interfaces, ensuring that capability-granting workflows themselves run in restricted contexts.

Least-Privilege Enforcement

The design follows the principle of least-privilege. A Gadget's code can only invoke capabilities the admin has explicitly enabled in the Durable Object's environment. Because the client-side iframe cannot open network connections, the only path to external services flows through the validated RPC layer, across the server-side bindings, and only if the specific action is permitted by the invoke() method logic.

Practical Implementation Examples

Defining the RPC Contract

The shared API definition establishes the communication boundary:

// packages/workshop-shared/src/api.ts
/** The public RPC API exposed to a Gadget's iframe. */
export interface GadgetApi {
  read(key: string): Promise<string | undefined>;
  write(key: string, value: string): Promise<void>;
  invoke(action: string, args: unknown[]): Promise<unknown>;
}

Creating the Sandboxed Environment

The frontend loader combines iframe attributes with backend-generated CSP headers:

// packages/workshop-frontend/src/gadgetLoader.ts
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';
iframe.srcdoc = gadgetHtml;
document.body.append(iframe);

Simultaneously, packages/workshop-backend/src/browser-export.ts attaches the restrictive CSP header to the HTTP response serving the iframe content.

Handling Server-Side Storage

The Durable Object implementation in packages/workshop-backend/src/agent.ts handles RPC calls using only explicitly bound resources:

export class GadgetDO implements GadgetApi {
  async read(key: string) {
    return this.env.KV.get(key);
  }

  async write(key: string, value: string) {
    await this.env.KV.put(key, value);
  }
}

Summary

  • Double sandbox architecture: Client-side iframe isolation (no network, CSP-enforced) combined with server-side Durable Object confinement (restricted Workers runtime).
  • Zero-trust network access: Gadgets cannot reach the internet unless the admin explicitly binds a fetch handler to the Durable Object's environment.
  • Type-safe RPC layer: Cap'n Web validates all cross-boundary communication via interfaces defined in packages/workshop-shared/src/api.ts.
  • Per-user isolation: Each Gadget instance runs in a dedicated Durable Object keyed to the specific user, preventing data leakage between sessions.
  • Capability-based security: The gadget stub exposes only methods defined in the RPC contract, and the server rejects unbound actions in the invoke() handler.

Frequently Asked Questions

What prevents a Gadget from making unauthorized network requests?

The client-side iframe is created with sandbox="allow-scripts" and a CSP header connect-src 'none' set in packages/workshop-backend/src/browser-export.ts, which disables fetch() and XMLHttpRequest. On the server side, the Durable Object runs in a restricted Workers environment where global fetch() is unavailable unless explicitly bound via this.env.ALLOWED_FETCH.

How does Cloudflare OS isolate one user's Gadget from another?

Each user receives a private Durable Object instance created in packages/workshop-backend/src/agent.ts. The object's unique key is derived from both the user ID and gadget ID, ensuring separate storage namespaces and execution contexts. The RPC layer validates that requests can only access the specific Durable Object associated with the authenticated user.

What is Cap'n Web RPC and why is it used for Gadget communication?

Cap'n Web RPC is the capability-based remote procedure call system used between the sandboxed iframe and the server. It is defined in packages/workshop-shared/src/api.ts and enforced via the @validateRpc() decorator. This system provides type-safe, validated communication with promise pipelining support, allowing the gadget stub to forward calls efficiently while maintaining strict security boundaries between the untrusted Gadget code and the kernel.

Where are the sandbox policies defined in the cloudflare-os codebase?

The iframe sandbox attributes are set in packages/workshop-frontend/src/gadgetLoader.ts (client-side) and packages/workshop-backend/src/browser-export.ts (CSP headers). The server-side execution restrictions are implemented in packages/workshop-backend/src/agent.ts through the Durable Object class and its controlled this.env bindings.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →