dorfteich/packages/plugin-sdk/src/host.ts
Claude Fable 5 0875e2a087
All checks were successful
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 2m57s
CI / Import/export fidelity gate (push) Successful in 46s
CD / Build and push images (push) Successful in 3m16s
CD / Deploy to Test (push) Successful in 8s
CI / Auth e2e pack (push) Successful in 4m8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
Add the sandbox host runtime for plugin iframes (#73)
Implements the security core of the plugin system: code-plugin surfaces
run in opaque-origin iframes (sandbox="allow-scripts", never
allow-same-origin) with a capability-filtered RPC bridge.

- api: serve a per-plugin sandbox frame document at
  /plugins/:id/:version/frame with a CSP that pins every load to the
  plugin's own asset path (built from APP_BASE_URL, not the request Host,
  so a Host-rewriting proxy cannot break it) and forbids network access
  (connect-src 'none'). Plugin assets get Access-Control-Allow-Origin: *
  so the null-origin frame can load its own module bundle.
- web: sandbox-host creates the frame, wires the SDK host bridge over a
  source-filtered postMessage transport, drives render under a 5 s
  deadline (hung/failed plugin -> placeholder, never a frozen page), and
  tears down on unmount. PluginFrame/PluginPreviewPage surface it; the
  built-in ui.resize handler clamps plugin-requested heights.
- plugin-sdk: host bridge reports gate violations via onViolation and
  registers a gated handler for every v1 method, so an undeclared
  capability is rejected with capability_not_permitted (not
  unknown_method).
- tests: SDK gate unit test; web sandbox unit tests (opaque origin,
  source filtering, timeout); and the e2e security pack with a permanent
  malicious fixture plugin proving no escape (DOM/cookies/storage/fetch/
  undeclared capability all blocked) plus well-behaved and hung cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 09:16:30 +02:00

129 lines
4.7 KiB
TypeScript

/**
* Host side of the RPC channel. The host owns the plugin's iframe, answers the
* plugin's capability calls (filtered against the manifest `permissions`), and
* drives the plugin's lifecycle methods.
*/
import { capabilityForMethod, METHOD_CAPABILITY, PLUGIN_LIFECYCLE_METHODS } from './capabilities';
import type { PluginLifecycleMethod } from './capabilities';
import type { PluginManifest } from './manifest';
import {
createRpcEndpoint,
RpcErrorObject,
type RpcEndpoint,
type RpcHandler,
type RpcRequestOptions,
type RpcTransport,
} from './rpc';
/**
* The host's implementations of the plugin API methods, keyed by method name
* (`getContent`, `listPages`, …). Each is executed against the viewing user's
* session by the host; only methods whose capability the plugin declared are
* ever reached (the router rejects the rest before calling in).
*/
export type HostCapabilityHandlers = Partial<Record<string, RpcHandler>>;
/** A capability call the gate refused, reported before the caller is told. */
export interface CapabilityViolation {
method: string;
capability?: string;
code: 'unknown_method' | 'capability_not_permitted';
}
export interface HostBridgeOptions {
manifest: Pick<PluginManifest, 'permissions'>;
/** Host implementations of the plugin API methods. */
capabilities: HostCapabilityHandlers;
transport: RpcTransport;
/** Default timeout for host→plugin lifecycle calls, in ms. */
timeoutMs?: number;
/** Called when the gate refuses a call (undeclared capability or unknown
* method) — hosts log these; a misbehaving plugin must leave a trace. */
onViolation?: (violation: CapabilityViolation) => void;
}
export interface HostBridge {
/** Invokes a plugin lifecycle method (`render`, `edit`, `destroy`). */
invoke: <T = unknown>(
method: PluginLifecycleMethod,
params?: unknown,
options?: RpcRequestOptions,
) => Promise<T>;
/** Tears down the channel and rejects any in-flight lifecycle calls. */
dispose: () => void;
/** The underlying endpoint, exposed for advanced host integrations. */
endpoint: RpcEndpoint;
}
/**
* Builds the host end of the RPC channel for one plugin surface. Incoming
* capability calls are routed through a permission gate: a method is answered
* only if it belongs to a v1 capability, that capability is declared in the
* manifest, and the host actually implements it — otherwise the caller gets a
* `capability_not_permitted` or `unknown_method` rejection.
*/
export function createHostBridge(options: HostBridgeOptions): HostBridge {
const declared = new Set(options.manifest.permissions);
const endpoint = createRpcEndpoint({
...options.transport,
timeoutMs: options.timeoutMs,
});
// Install one gated handler per known API method. The gate resolves the
// capability, checks the manifest declared it, then delegates to the host
// implementation (if any).
const gate = (method: string): RpcHandler => {
return async (params) => {
const capability = capabilityForMethod(method);
if (!capability) {
options.onViolation?.({ method, code: 'unknown_method' });
throw new RpcErrorObject({
code: 'unknown_method',
message: `"${method}" is not a plugin API method`,
});
}
if (!declared.has(capability)) {
options.onViolation?.({ method, capability, code: 'capability_not_permitted' });
throw new RpcErrorObject({
code: 'capability_not_permitted',
message: `plugin did not declare capability "${capability}" required for "${method}"`,
});
}
const impl = options.capabilities[method];
if (!impl) {
throw new RpcErrorObject({
code: 'unknown_method',
message: `host does not implement "${method}"`,
});
}
return impl(params);
};
};
// Register a gated handler for every v1 API method — not just the ones the
// host implements. That way an *undeclared* call is answered with
// `capability_not_permitted` (and reported via `onViolation`) even when the
// host has no implementation for it; only methods outside the v1 surface
// fall through to the endpoint's plain `unknown_method` response.
for (const method of Object.keys(METHOD_CAPABILITY)) {
endpoint.setHandler(method, gate(method));
}
return {
invoke: (method, params, requestOptions) => {
if (!PLUGIN_LIFECYCLE_METHODS.includes(method)) {
return Promise.reject(
new RpcErrorObject({
code: 'unknown_method',
message: `"${method}" is not a plugin lifecycle method`,
}),
);
}
return endpoint.request(method, params, requestOptions);
},
dispose: () => endpoint.dispose(),
endpoint,
};
}