dorfteich/apps/web/e2e/plugins.spec.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

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();
});