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
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
129 lines
4.7 KiB
TypeScript
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,
|
|
};
|
|
}
|