diff --git a/.dockerignore b/.dockerignore index b6764a50f..68da62e5b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,9 @@ lite-image !.env.dev.example scripts/ !scripts/lite/ +!scripts/build-plugin-sandbox.mjs +# Built in the image by scripts/build-plugin-sandbox.mjs +public/plugin-sandbox-assets/ TODO.md *.md !README.md diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml index 3d4e08e4f..4336d783c 100644 --- a/.github/workflows/perf-guard.yml +++ b/.github/workflows/perf-guard.yml @@ -41,7 +41,7 @@ jobs: DEV_MOCK_JMAP: "true" NEXT_TELEMETRY_DISABLED: "1" GIT_COMMIT: ${{ github.sha }} - run: npx next build --webpack + run: node scripts/build-plugin-sandbox.mjs && npx next build --webpack - name: Run perf guard env: diff --git a/.gitignore b/.gitignore index 276b85604..dd9bc4281 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ /.next/ /out/ /lite-image/ +# scripts/build-plugin-sandbox.mjs +/public/plugin-sandbox-assets/ # production /build diff --git a/Dockerfile b/Dockerfile index c9efc1cfa..f41759013 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN rm -rf "app/api/dev-jmap" && \ find . -path ./node_modules -prune -o \ \( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \ -prune -exec rm -rf {} \; && \ + node scripts/build-plugin-sandbox.mjs && \ npx next build --webpack FROM node:24-alpine AS runner diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx deleted file mode 100644 index 032544b81..000000000 --- a/app/(sandbox)/layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Metadata } from 'next'; -import type { ReactNode } from 'react'; -import './plugin-kit.css'; - -// The plugin sandbox iframe runs with an opaque origin (the `sandbox` -// attribute in production excludes `allow-same-origin` for isolation). Any -// asset request from this layout is then cross-origin from the "null" origin -// to the host origin. Fonts need CORS, so there are no font imports. The one -// stylesheet is plugin-kit.css, the styles of the plugin UI kit: Next serves -// it from /_next/static, which answers with Access-Control-Allow-Origin: * -// (next.config.ts), and the sandbox CSP allows styles from 'self'. Plugin -// bundles and host API calls travel over the postMessage RPC bridge. - -export const metadata: Metadata = { - title: 'Plugin sandbox', - robots: { index: false, follow: false }, -}; - -export default function PluginSandboxLayout({ children }: { children: ReactNode }) { - return ( - - - {/* next dev puts its indicator into every document, so each plugin - slot showed its own badge. Plugins have no use for it. */} - {process.env.NODE_ENV === 'development' && ( - - )} - {children} - - - ); -} diff --git a/app/(sandbox)/plugin-kit.css b/app/(sandbox)/plugin-kit.css index 6265d9eae..ff06f66b4 100644 --- a/app/(sandbox)/plugin-kit.css +++ b/app/(sandbox)/plugin-kit.css @@ -1,5 +1,5 @@ /* Styles for the plugin UI kit (lib/plugin-sandbox/ui-kit), loaded into - every plugin iframe. Built from the same Tailwind classes as the app's own + every plugin iframe; scripts/build-plugin-sandbox.mjs compiles it. Built from the same Tailwind classes as the app's own components, so a kit Button is the app's Button. Only the kit's sources are scanned, which keeps the file small. diff --git a/app/(sandbox)/plugin-sandbox-privileged/page.tsx b/app/(sandbox)/plugin-sandbox-privileged/page.tsx deleted file mode 100644 index 0f3d431d3..000000000 --- a/app/(sandbox)/plugin-sandbox-privileged/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; - -// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the -// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle -// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin -// approval) is enforced host-side before this route is ever framed; the page -// itself carries no extra privilege. -// -// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in -// Next's injected hydration/chunk scripts. -export const dynamic = 'force-dynamic'; - -export default function PrivilegedPluginSandboxPage() { - return ; -} diff --git a/app/(sandbox)/plugin-sandbox-privileged/route.ts b/app/(sandbox)/plugin-sandbox-privileged/route.ts new file mode 100644 index 000000000..5ed0a733b --- /dev/null +++ b/app/(sandbox)/plugin-sandbox-privileged/route.ts @@ -0,0 +1,12 @@ +import { sandboxDocument } from '@/lib/plugin-sandbox/sandbox-document'; + +// Privileged-tier sandbox document. Identical to /plugin-sandbox, but the +// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle +// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin +// approval) is enforced host-side before this route is ever framed; the +// document itself carries no extra privilege. +export const dynamic = 'force-dynamic'; + +export function GET(request: Request): Promise { + return sandboxDocument(request); +} diff --git a/app/(sandbox)/plugin-sandbox/page.tsx b/app/(sandbox)/plugin-sandbox/page.tsx deleted file mode 100644 index fa9576eed..000000000 --- a/app/(sandbox)/plugin-sandbox/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; - -// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in -// Next's injected hydration/chunk scripts. With force-static, those scripts -// render without a nonce and the strict sandbox CSP blocks them. -export const dynamic = 'force-dynamic'; - -export default function PluginSandboxPage() { - return ; -} diff --git a/app/(sandbox)/plugin-sandbox/route.ts b/app/(sandbox)/plugin-sandbox/route.ts new file mode 100644 index 000000000..c02b187cd --- /dev/null +++ b/app/(sandbox)/plugin-sandbox/route.ts @@ -0,0 +1,9 @@ +import { sandboxDocument } from '@/lib/plugin-sandbox/sandbox-document'; + +// The untrusted plugin iframe document (sandbox="allow-scripts", opaque +// origin). Dynamic: the CSP nonce from proxy.ts is per request. +export const dynamic = 'force-dynamic'; + +export function GET(request: Request): Promise { + return sandboxDocument(request); +} diff --git a/lib/__tests__/plugin-sandbox-build.test.ts b/lib/__tests__/plugin-sandbox-build.test.ts new file mode 100644 index 000000000..71c6128df --- /dev/null +++ b/lib/__tests__/plugin-sandbox-build.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildPluginSandbox } from '../../scripts/build-plugin-sandbox.mjs'; + +type Outputs = Record; imports: Array<{ path: string; kind: string }> }>; + +const dirs: string[] = []; +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }); +}); + +// Every plugin frame parses what its document loads up front, again in every +// frame (opaque origins get no code cache). +describe('plugin sandbox build', () => { + it('loads React, the runtime and the kit up front, and every other icon only on demand', async () => { + const outdir = await mkdtemp(path.join(tmpdir(), 'plugin-sandbox-')); + dirs.push(outdir); + const { script, style, outputs } = await buildPluginSandbox({ outdir }) as { script: string; style: string; outputs: Outputs }; + + const byName = new Map(Object.entries(outputs).map(([file, out]) => [path.basename(file), out])); + const eager = new Set(); + const walk = (name: string) => { + if (eager.has(name)) return; + eager.add(name); + for (const imp of byName.get(name)!.imports) if (imp.kind === 'import-statement') walk(path.basename(imp.path)); + }; + walk(script); + const eagerBytes = [...eager].reduce((sum, n) => sum + byName.get(n)!.bytes, 0); + const eagerInputs = [...eager].flatMap((n) => Object.keys(byName.get(n)!.inputs)).map((f) => f.replace(/\\/g, '/')); + + // The first build put all 5000 Tabler icons (2.6 MB) in an eager chunk. + expect(eagerBytes).toBeLessThan(600_000); + expect(eagerInputs.filter((f) => f.includes('node_modules/next/'))).toEqual([]); + expect(eagerInputs.filter((f) => /(^|\/)stores\//.test(f))).toEqual([]); + // ...but they are still there for an icon name the kit doesn't ship. + const lazy = [...byName.keys()].filter((n) => n.endsWith('.js') && !eager.has(n)); + expect(lazy.some((n) => byName.get(n)!.bytes > 1_000_000)).toBe(true); + + const manifest = JSON.parse(await readFile(path.join(outdir, 'manifest.json'), 'utf8')); + expect(manifest).toEqual({ script, style, preload: [...eager].filter((n) => n !== script) }); + expect(await readFile(path.join(outdir, style), 'utf8')).toContain('.bk'); + }, 120_000); +}); diff --git a/lib/__tests__/plugin-sandbox-document.test.ts b/lib/__tests__/plugin-sandbox-document.test.ts new file mode 100644 index 000000000..22f19d8d4 --- /dev/null +++ b/lib/__tests__/plugin-sandbox-document.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const files = vi.hoisted(() => new Map()); +vi.mock('node:fs/promises', async (orig) => ({ + ...(await orig()), + readFile: vi.fn(async (p: string) => { + const key = String(p).replace(/\\/g, '/').split('/public/')[1]; + if (key && files.has(key)) return files.get(key)!; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }), +})); + +import { GET as untrusted } from '@/app/(sandbox)/plugin-sandbox/route'; +import { GET as privileged } from '@/app/(sandbox)/plugin-sandbox-privileged/route'; + +const MANIFEST = 'plugin-sandbox-assets/manifest.json'; + +function request(nonce = 'n0nce'): Request { + return new Request('http://localhost:3000/plugin-sandbox', { headers: { 'x-nonce': nonce } }); +} + +beforeEach(() => { + files.clear(); + files.set(MANIFEST, JSON.stringify({ script: 'runtime-ABC.js', style: 'kit-123.css', preload: ['chunk-ICONS.js'] })); + vi.stubEnv('NODE_ENV', 'development'); // re-read the manifest for every test +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +// Plugin frames used to load a Next page: its client router and hydration +// in every frame, and a server render per frame. +describe('plugin sandbox document', () => { + it.each([['/plugin-sandbox', untrusted], ['/plugin-sandbox-privileged', privileged]])( + '%s is a plain page with the runtime script and the kit stylesheet', async (_path, GET) => { + const res = await GET(request()); + const html = await res.text(); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + expect(html).toContain(''); + expect(html).toContain(''); + // Fetched with the script, not after it has been parsed. + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).not.toContain('/_next/'); + }); + + it('serves the assets under the base path', async () => { + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail'); + const html = await (await untrusted(request())).text(); + + expect(html).toContain('src="/webmail/plugin-sandbox-assets/runtime-ABC.js"'); + expect(html).toContain('href="/webmail/plugin-sandbox-assets/kit-123.css"'); + }); + + it('does not let the nonce header break out of its attribute', async () => { + const html = await (await untrusted(request('x">'))).text(); + + expect(html).not.toContain(''); + expect(html.match(/` + + '
'; + return new Response(html, { + headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, + }); +} diff --git a/lib/plugin-sandbox/sandbox-entry.ts b/lib/plugin-sandbox/sandbox-entry.ts new file mode 100644 index 000000000..1d2241119 --- /dev/null +++ b/lib/plugin-sandbox/sandbox-entry.ts @@ -0,0 +1,7 @@ +// Script of the plugin sandbox document (see sandbox-document.ts), bundled by +// scripts/build-plugin-sandbox.mjs. The document already has the root +// element, so the runtime only has to bind to its host. + +import { startSandboxRuntime } from './runtime'; + +startSandboxRuntime(); diff --git a/next.config.ts b/next.config.ts index fec618fa1..75a2d03bd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -81,6 +81,16 @@ const nextConfig: NextConfig = { source: "/_next/static/:path*", headers: [{ key: "Access-Control-Allow-Origin", value: "*" }], }, + { + // The plugin sandbox document's script and stylesheet + // (scripts/build-plugin-sandbox.mjs): module scripts, so the + // opaque-origin frames fetch them in CORS mode. Content-hashed. + source: "/plugin-sandbox-assets/:path*", + headers: [ + { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, + ], + }, ]; }, }), diff --git a/package.json b/package.json index 8abe1a163..0a06a4575 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "typescript" ], "scripts": { - "dev": "next dev --turbopack", - "build": "next build --turbopack", + "dev": "node scripts/build-plugin-sandbox.mjs && next dev --turbopack", + "build": "node scripts/build-plugin-sandbox.mjs && next build --turbopack", "build:lite": "node scripts/lite/build.mjs", "lite:serve": "node scripts/lite/serve.mjs out 4173", "test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts", diff --git a/proxy.ts b/proxy.ts index c4c1f5b67..fce4ef7b9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -56,13 +56,13 @@ const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)/; // What Next serves from public/: a single dotted segment at the root // (/sw.js, /favicon.ico, /manifest.webmanifest) and the branding/, -// notification/ and demo/ asset folders. The upstream matcher treated EVERY path whose +// notification/, demo/ and plugin-sandbox-assets/ asset folders. The upstream matcher treated EVERY path whose // last segment has a dot as a static file, but the [[...segments]] catch-alls // under //mail, /calendar, /contacts and /files make // /en/mail/folder/inbox/statement.pdf a real signed-in page, and it rendered // without a CSP or any other security header (GHSA-xvjh-v9c6-qcvc). Only what // is genuinely static may skip locale routing and the headers. -const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo)\//; +const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo|plugin-sandbox-assets)\//; export function isStaticAssetPath(pathname: string): boolean { return STATIC_ASSET_PATTERN.test(pathname); diff --git a/scripts/build-plugin-sandbox.mjs b/scripts/build-plugin-sandbox.mjs new file mode 100644 index 000000000..4ead605c1 --- /dev/null +++ b/scripts/build-plugin-sandbox.mjs @@ -0,0 +1,133 @@ +// Builds the plugin sandbox document's script and stylesheet into +// public/plugin-sandbox-assets/, with a manifest.json naming them for +// lib/plugin-sandbox/sandbox-document.ts. +// +// Every plugin iframe loads these. They used to come from a Next page, so +// each frame also loaded, parsed and hydrated Next's client router, and the +// server rendered the page for every frame. Sandboxed frames have opaque +// origins, so the browser's code cache never helps: what a frame loads, it +// parses again, on the host page's main thread. +// +// node scripts/build-plugin-sandbox.mjs (npm run build and dev run it) + +import { build } from 'esbuild'; +import postcss from 'postcss'; +import tailwind from '@tailwindcss/postcss'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const DEFAULT_OUTDIR = path.join(root, 'public', 'plugin-sandbox-assets'); + +// components/icons imports a few hundred Tabler icons by name, and for any +// other name `import('@tabler/icons-react')` loads all of them. Both went +// through the package's entry module, which also exports the namespace of +// every icon, so all 5000 icons (2.6 MB) ended up in a chunk every frame +// loaded. Like Next's optimizePackageImports, named imports and re-exports +// are pointed at the icons' own files; only the dynamic import gets them all. +const tablerDir = path.join(root, 'node_modules/@tabler/icons-react/dist/esm'); +const tablerFiles = new Map(); +for (const m of (await readFile(path.join(tablerDir, 'tabler-icons-react.mjs'), 'utf8')) + .matchAll(/export \{([^}]+)\} from '\.\/icons\/([^']+)';/g)) { + for (const spec of m[1].split(',')) { + const name = spec.trim().match(/^default as (\w+)$/)?.[1]; + if (name) tablerFiles.set(name, path.join(tablerDir, 'icons', m[2]).replace(/\\/g, '/')); + } +} +function splitTablerSpecifiers(keyword, specs, from) { + return specs.split(',').map((s) => s.trim()).filter(Boolean).map((spec) => { + const [imported, local = imported] = spec.split(/\s+as\s+/); + const file = tablerFiles.get(imported); + if (!file) return `${keyword} { ${spec} } from '${from}';`; + return keyword === 'import' + ? `import ${local} from '${file}';` + : `export { default as ${local} } from '${file}';`; + }).join('\n'); +} +const tablerPerIcon = { + name: 'tabler-per-icon', + setup(b) { + b.onResolve({ filter: /^@tabler\/icons-react$/ }, (args) => + args.kind === 'dynamic-import' ? { path: 'all-icons', namespace: 'tabler-all' } : undefined); + b.onLoad({ filter: /.*/, namespace: 'tabler-all' }, () => ({ + contents: `export * as icons from '${tablerDir.replace(/\\/g, '/')}/icons/index.mjs';\n` + + `export * as iconsList from '${tablerDir.replace(/\\/g, '/')}/icons-list.mjs';\n`, + resolveDir: root, + })); + b.onLoad({ filter: /[\\/](components|lib)[\\/].*\.tsx?$/ }, async (args) => { + const source = await readFile(args.path, 'utf8'); + if (!source.includes('@tabler/icons-react')) return undefined; + const contents = source.replace( + /\b(import|export)\s*\{([^}]*)\}\s*from\s*['"]@tabler\/icons-react['"];?/g, + (_, keyword, specs) => splitTablerSpecifiers(keyword, specs, '@tabler/icons-react'), + ); + return { contents, loader: args.path.endsWith('.tsx') ? 'tsx' : 'ts' }; + }); + }, +}; + +/** + * Build into `outdir` (emptied first). Resolves with the file names and + * esbuild's metafile outputs. + */ +export async function buildPluginSandbox({ outdir = DEFAULT_OUTDIR } = {}) { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + + // ES modules so the rarely used parts (every Tabler icon, for an icon name + // the kit doesn't ship) stay in chunks that load on demand. + const js = await build({ + entryPoints: { runtime: path.join(root, 'lib/plugin-sandbox/sandbox-entry.ts') }, + bundle: true, + splitting: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + outdir, + entryNames: '[name]-[hash]', + chunkNames: 'chunk-[hash]', + minify: true, + legalComments: 'none', + jsx: 'automatic', + define: { 'process.env.NODE_ENV': '"production"' }, + alias: { '@': root }, + loader: { '.css': 'empty' }, + plugins: [tablerPerIcon], + metafile: true, + logLevel: 'warning', + }); + const script = Object.entries(js.metafile.outputs).find(([, out]) => out.entryPoint)?.[0]; + if (!script) throw new Error('esbuild produced no entry chunk'); + + // The UI kit's styles, through the same Tailwind setup as the app's CSS. + const cssPath = path.join(root, 'app/(sandbox)/plugin-kit.css'); + const css = await postcss([tailwind({ optimize: { minify: true } })]).process(await readFile(cssPath, 'utf8'), { from: cssPath }); + const style = `kit-${createHash('sha256').update(css.css).digest('hex').slice(0, 12)}.css`; + await writeFile(path.join(outdir, style), css.css); + + // The chunks the script imports statically, so the document can fetch them + // with it instead of after it has been parsed. + const outputs = new Map(Object.entries(js.metafile.outputs).map(([f, o]) => [path.basename(f), o])); + const preload = []; + const visit = (name) => { + for (const imp of outputs.get(name)?.imports ?? []) { + const dep = path.basename(imp.path); + if (imp.kind !== 'import-statement' || preload.includes(dep)) continue; + preload.push(dep); + visit(dep); + } + }; + visit(path.basename(script)); + + const manifest = { script: path.basename(script), style, preload }; + await writeFile(path.join(outdir, 'manifest.json'), `${JSON.stringify(manifest)}\n`); + return { ...manifest, outputs: js.metafile.outputs, css: css.css }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { outputs, style, css } = await buildPluginSandbox(); + const sizes = Object.entries(outputs).filter(([f]) => f.endsWith('.js')).map(([f, o]) => `${path.basename(f)} ${o.bytes}`); + console.log(`[build-plugin-sandbox] ${sizes.join(', ')}, ${style} ${css.length}`); +}