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
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { BrowserContext } from '@playwright/test';
|
|
|
|
import { contextForUser } from './helpers';
|
|
import {
|
|
fixtureManifest,
|
|
HUNG_SOURCE,
|
|
MALICIOUS_SOURCE,
|
|
pluginZip,
|
|
WELL_BEHAVED_SOURCE,
|
|
} from './plugin-fixtures';
|
|
|
|
/**
|
|
* Sandbox security pack (issue #73, ADR 0008): drives the admin plugin
|
|
* preview page — the same sandbox runtime pages embed — with a well-behaved,
|
|
* a malicious, and a hung fixture plugin. The malicious plugin is the
|
|
* permanent security regression asset: every probe it runs must stay
|
|
* "blocked" forever.
|
|
*/
|
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
|
|
|
async function installPlugin(
|
|
context: BrowserContext,
|
|
id: string,
|
|
name: string,
|
|
permissions: string[],
|
|
source: string,
|
|
): Promise<void> {
|
|
// Idempotent per run: remove any previous installation of the fixture id.
|
|
await context.request.delete(`/api/v1/admin/plugins/${id}`);
|
|
const response = await context.request.post('/api/v1/admin/plugins', {
|
|
multipart: {
|
|
file: {
|
|
name: `${id}.zip`,
|
|
mimeType: 'application/zip',
|
|
buffer: pluginZip(fixtureManifest(id, name, permissions), source),
|
|
},
|
|
},
|
|
});
|
|
expect(response.status(), await response.text()).toBe(201);
|
|
}
|
|
|
|
test('a well-behaved plugin renders and resizes its own frame', async ({ browser }) => {
|
|
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
await installPlugin(context, 'e2e-behaved', 'Behaved', ['ui'], WELL_BEHAVED_SOURCE);
|
|
|
|
const page = await context.newPage();
|
|
await page.goto('/admin/plugins/e2e-behaved/preview');
|
|
|
|
const host = page.locator('.plugin-frame-host');
|
|
await expect(host).toHaveAttribute('data-state', 'ready', { timeout: 10000 });
|
|
const frame = page.frameLocator('.plugin-frame');
|
|
await expect(frame.locator('body')).toHaveText('plugin-ok');
|
|
// `ui.resize` was declared → the built-in handler applied the height.
|
|
await expect(page.locator('.plugin-frame')).toHaveCSS('height', '321px');
|
|
});
|
|
|
|
test('a malicious plugin cannot escape the sandbox (security regression asset)', async ({
|
|
browser,
|
|
}) => {
|
|
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
await installPlugin(context, 'e2e-malicious', 'Malicious', [], MALICIOUS_SOURCE);
|
|
|
|
const page = await context.newPage();
|
|
await page.goto('/admin/plugins/e2e-malicious/preview');
|
|
|
|
const frame = page.frameLocator('.plugin-frame');
|
|
// The final probe reports last — once it is attached, all verdicts are in.
|
|
// (The marker divs are empty and zero-size, so assert attachment, not
|
|
// visibility.)
|
|
await expect(frame.locator('[data-probe="done"]')).toBeAttached({ timeout: 15000 });
|
|
|
|
for (const probe of [
|
|
'parentDom',
|
|
'cookie',
|
|
'localStorage',
|
|
'fetchSameOrigin',
|
|
'fetchExternal',
|
|
'undeclaredCapability',
|
|
]) {
|
|
await expect(frame.locator(`[data-probe="${probe}"]`), probe).toHaveAttribute(
|
|
'data-result',
|
|
'blocked',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a hung plugin collapses to the timeout placeholder; the page stays responsive', async ({
|
|
browser,
|
|
}) => {
|
|
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
await installPlugin(context, 'e2e-hung', 'Hung', [], HUNG_SOURCE);
|
|
|
|
const page = await context.newPage();
|
|
await page.goto('/admin/plugins/e2e-hung/preview');
|
|
|
|
const host = page.locator('.plugin-frame-host');
|
|
// The placeholder must appear within the 5 s deadline (plus render slack).
|
|
await expect(host).toHaveAttribute('data-state', 'failed', { timeout: 7000 });
|
|
await expect(page.locator('.plugin-frame-host__status--failed')).toBeVisible();
|
|
await expect(page.locator('.plugin-frame')).toHaveCount(0);
|
|
|
|
// The app around the dead plugin still responds to input.
|
|
await expect(page.locator('.plugin-preview__permissions, h1').first()).toBeVisible();
|
|
await page.getByRole('heading', { level: 1 }).click();
|
|
});
|