diff --git a/lib/__tests__/plugin-sandbox-route-gate.test.ts b/lib/__tests__/plugin-sandbox-route-gate.test.ts new file mode 100644 index 000000000..ffd3bb318 --- /dev/null +++ b/lib/__tests__/plugin-sandbox-route-gate.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment node + * + * /plugin-sandbox and /plugin-sandbox-privileged are documents that carry + * 'unsafe-eval' so plugin bundles can run. They only make sense as iframes + * created by the host bridge, yet they used to answer any top-level load, + * which is what let a foreign site window.open() them and feed them code + * (GHSA-96cx-gx36-3g79). The proxy now refuses top-level document loads and + * closes the routes entirely while the plugin feature is off. The runtime's + * own host gate stays the primary defence for browsers without Sec-Fetch-*. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +let pluginsEnabled = true; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn((_key: string, fallback: unknown) => fallback), + getPolicy: vi.fn(() => ({ features: { pluginsEnabled } })), + }, +})); +vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') })); +vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) })); +// The sandbox routes skip the intl middleware; ordinary pages pass through it. +vi.mock('next-intl/middleware', async () => { + const { NextResponse } = await import('next/server'); + return { default: () => () => NextResponse.next() }; +}); + +import { proxy } from '@/proxy'; + +const SANDBOX_ROUTES = ['/plugin-sandbox', '/plugin-sandbox-privileged']; + +function load(path: string, headers: Record = {}): Promise { + return proxy(new NextRequest(`http://localhost:3000${path}`, { headers })); +} + +const IFRAME_LOAD = { 'sec-fetch-dest': 'iframe', 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'navigate' }; + +beforeEach(() => { + vi.clearAllMocks(); + pluginsEnabled = true; +}); + +describe('plugin sandbox routes (GHSA-96cx-gx36-3g79)', () => { + it.each(SANDBOX_ROUTES)('serves %s to the host bridge iframe with the eval CSP', async (path) => { + const response = await load(path, IFRAME_LOAD); + + expect(response.status).toBe(200); + const csp = response.headers.get('content-security-policy') ?? ''; + expect(csp).toContain("'unsafe-eval'"); + expect(csp).toContain("frame-ancestors 'self'"); + }); + + it.each(SANDBOX_ROUTES)('refuses %s as a top-level document opened by another site', async (path) => { + const response = await load(path, { 'sec-fetch-dest': 'document', 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }); + + expect(response.status).toBe(403); + expect(response.headers.get('content-security-policy')).toBeNull(); + }); + + it.each(SANDBOX_ROUTES)('refuses %s as a top-level document typed into the address bar', async (path) => { + const response = await load(path, { 'sec-fetch-dest': 'document', 'sec-fetch-site': 'none', 'sec-fetch-mode': 'navigate' }); + + expect(response.status).toBe(403); + }); + + it('refuses paths nested under the sandbox routes the same way', async () => { + const response = await load('/plugin-sandbox/anything', { 'sec-fetch-dest': 'document' }); + + expect(response.status).toBe(403); + }); + + it.each(SANDBOX_ROUTES)('still serves %s to a browser that sends no Sec-Fetch-Dest', async (path) => { + // Pre-2020 browsers: the runtime host gate is the only defence there. + const response = await load(path); + + expect(response.status).toBe(200); + }); + + it('lets the router refetch the sandbox page as RSC (Sec-Fetch-Dest: empty)', async () => { + const response = await load('/plugin-sandbox?_rsc=abc12', { 'sec-fetch-dest': 'empty', 'sec-fetch-site': 'same-origin', rsc: '1' }); + + expect(response.status).toBe(200); + }); + + it.each(SANDBOX_ROUTES)('does not serve %s at all while plugins are disabled', async (path) => { + pluginsEnabled = false; + + expect((await load(path, IFRAME_LOAD)).status).toBe(403); + expect((await load(path)).status).toBe(403); + }); + + it('leaves ordinary pages alone and never hands them the eval CSP', async () => { + const response = await load('/en/calendar', { 'sec-fetch-dest': 'document', 'sec-fetch-site': 'none' }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-security-policy')).not.toContain("'unsafe-eval'"); + }); +}); diff --git a/lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts b/lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts new file mode 100644 index 000000000..d1ffd7b94 --- /dev/null +++ b/lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts @@ -0,0 +1,144 @@ +/** + * The plugin sandbox runtime hands `init.code` to `new Function` under an + * 'unsafe-eval' CSP. It used to pin "the host" to whichever window posted to + * it first, so any website could window.open() /plugin-sandbox, post its own + * `init` before a real host existed, and run code in the webmail origin with + * the victim's session (GHSA-96cx-gx36-3g79). The host is now fixed from + * browser-owned facts before the listener exists: the framing window + * (`window.parent`) posting from this document's own URL origin. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; + +const HOST_ORIGIN = window.location.origin; +const ATTACKER_ORIGIN = 'https://attacker.example'; + +// The bundle leaves a marker behind so the test can tell whether it ran. +const BUNDLE = 'globalThis.__sandboxBundleRan = location.origin; module.exports = {};'; +const marker = globalThis as { __sandboxBundleRan?: string }; + +function initMessage() { + return { + type: 'init', + payload: { + mode: 'background', + pluginId: 'x', + tier: 'privileged', + locale: 'en', + manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} }, + code: BUNDLE, + }, + }; +} + +/** Dispatch a message event the way the browser would for `source`. */ +function post(source: unknown, origin: string, data: unknown): void { + const ev = new MessageEvent('message', { data, origin }); + // jsdom only accepts real Window objects in the constructor's `source`; + // override the prototype getter on the instance instead. + Object.defineProperty(ev, 'source', { value: source }); + window.dispatchEvent(ev); +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +let hostWindow: { postMessage: ReturnType }; +let framed = false; +const realParent = Object.getOwnPropertyDescriptor(window, 'parent'); + +beforeEach(() => { + // The runtime keeps its host binding and boot flag at module scope. + vi.resetModules(); + delete marker.__sandboxBundleRan; + hostWindow = { postMessage: vi.fn() }; + framed = false; + Object.defineProperty(window, 'parent', { + configurable: true, + get: () => (framed ? (hostWindow as unknown as Window) : window), + }); +}); + +afterEach(() => { + cleanup(); + if (realParent) Object.defineProperty(window, 'parent', realParent); +}); + +async function mountRuntime(): Promise { + const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime'); + render(React.createElement(SandboxRuntime)); +} + +function postedTypes(): Array<[string, string]> { + return hostWindow.postMessage.mock.calls.map(([msg, target]) => [(msg as { type: string }).type, target as string]); +} + +describe('plugin sandbox runtime host gate (GHSA-96cx-gx36-3g79)', () => { + it('stays inert when loaded as a top-level document, whoever posts to it', async () => { + framed = false; + await mountRuntime(); + + // The window.open() attack: the opener posts init before any host could. + const opener = { postMessage: vi.fn() }; + post(opener, ATTACKER_ORIGIN, initMessage()); + // Even a same-origin sender gets nothing - there is no framing host. + post(hostWindow, HOST_ORIGIN, initMessage()); + await flush(); + + expect(marker.__sandboxBundleRan).toBeUndefined(); + expect(opener.postMessage).not.toHaveBeenCalled(); + expect(hostWindow.postMessage).not.toHaveBeenCalled(); + }); + + it('pings only the framing window, targeted at its own origin', async () => { + framed = true; + await mountRuntime(); + + expect(postedTypes()).toEqual([['sandbox-ready', HOST_ORIGIN]]); + }); + + it('ignores a sender that is not the framing window, and is not poisoned by it', async () => { + framed = true; + await mountRuntime(); + + const other = { postMessage: vi.fn() }; + post(other, HOST_ORIGIN, initMessage()); + await flush(); + expect(marker.__sandboxBundleRan).toBeUndefined(); + expect(other.postMessage).not.toHaveBeenCalled(); + + // The real host still boots afterwards: the rejected message did not + // consume the one-shot init. + post(hostWindow, HOST_ORIGIN, initMessage()); + await flush(); + expect(marker.__sandboxBundleRan).toBe(HOST_ORIGIN); + }); + + it('ignores the framing window when it posts from another origin', async () => { + framed = true; + await mountRuntime(); + + post(hostWindow, ATTACKER_ORIGIN, initMessage()); + await flush(); + expect(marker.__sandboxBundleRan).toBeUndefined(); + expect(postedTypes()).toEqual([['sandbox-ready', HOST_ORIGIN]]); + + post(hostWindow, HOST_ORIGIN, initMessage()); + await flush(); + expect(marker.__sandboxBundleRan).toBe(HOST_ORIGIN); + }); + + it('boots the bundle for the framing window on its own origin and reports back to it', async () => { + framed = true; + await mountRuntime(); + + post(hostWindow, HOST_ORIGIN, initMessage()); + await flush(); + + expect(marker.__sandboxBundleRan).toBe(HOST_ORIGIN); + expect(postedTypes()).toEqual([ + ['sandbox-ready', HOST_ORIGIN], + ['init-done', HOST_ORIGIN], + ]); + }); +}); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 56b36383c..a30dc9fee 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -3,9 +3,9 @@ // Runtime that boots inside the null-origin plugin sandbox iframe. // // Lifecycle: -// 1. Iframe loads → posts 'sandbox-ready' to parent (targetOrigin '*' is OK; -// the message carries no secrets, and the parent's first inbound message -// gives us the origin to pin for everything that follows). +// 1. Iframe loads → posts 'sandbox-ready' to the window that framed it +// (`window.parent`, targeted at this document's own URL origin - see +// `resolveHost`). A top-level load has no host and stays inert. // 2. Parent posts 'init' with the bundle code + manifest + mode/slot. // 3. We evaluate the bundle in a `new Function` scope with React/ReactDOM // injected as globals; the bundle is CommonJS-style (`module.exports = { @@ -70,6 +70,39 @@ function sendToHost(msg: SandboxToHost): void { parentWindow.postMessage(msg, parentOrigin); } +// ─── Host binding ──────────────────────────────────────────── + +/** + * The one window allowed to drive this runtime, and the origin it must post + * from. Both are fixed from browser-owned facts at load time and never + * learned from a message: + * + * - `window.parent` is the document that framed us. The host bridge creates + * that iframe itself, so this is the mirror of its own contentWindow check. + * A top-level load (window.open from another site, a typed URL) has no + * parent - `parent === window` - and gets no host at all: `window.opener` + * is never a host. + * - `location.origin` is the URL origin even when this iframe runs with an + * opaque origin (sandbox="allow-scripts"), and the host page is served + * from that same origin: the bridge loads a same-origin path and the route + * CSP pins `frame-ancestors 'self'`. + * + * Pinning the first sender instead (pre-fix) let any website window.open() + * the route, post its own `init` before a real host could, and run its code + * under this document's `'unsafe-eval'` CSP in the webmail origin + * (GHSA-96cx-gx36-3g79). Returns null when there is no acceptable host. + */ +function resolveHost(): { window: Window; origin: string } | null { + if (typeof window === 'undefined') return null; + const parent = window.parent; + if (!parent || parent === window) return null; + const origin = window.location.origin; + // 'null' is what opaque URL origins (blob:, data:, file:) serialise to; an + // attacker frame could match it, so it never counts as a host origin. + if (!origin || origin === 'null') return null; + return { window: parent, origin }; +} + // ─── Theme replay ──────────────────────────────────────────── const THEME_STYLE_ID = '__plugin_host_theme'; @@ -643,14 +676,12 @@ async function handleInit(payload: InitPayload): Promise { // ─── Host message handler ──────────────────────────────────── function handleHostMessage(ev: MessageEvent): void { - // First inbound message pins source + origin. Reject everything else. - if (!parentWindow) { - if (!ev.source || ev.source === window) return; - parentWindow = ev.source as Window; - parentOrigin = ev.origin || null; - } + // Only the framing window, posting from our own origin, may talk to us. + // Both were bound by `resolveHost` before this listener was installed; a + // message never establishes them (GHSA-96cx-gx36-3g79). + if (!parentWindow || !parentOrigin) return; if (ev.source !== parentWindow) return; - if (parentOrigin && ev.origin !== parentOrigin) return; + if (ev.origin !== parentOrigin) return; const msg = ev.data as HostToSandbox; if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return; @@ -729,13 +760,20 @@ function handleHostMessage(ev: MessageEvent): void { export function SandboxRuntime(): React.JSX.Element { useEffect(() => { + // Not framed by a same-origin host: stay inert. No listener, no ping - + // nothing a window.open()-ing site could talk to. + const host = resolveHost(); + if (!host) return; + parentWindow = host.window; + parentOrigin = host.origin; window.addEventListener('message', handleHostMessage); - // Initial ping. We don't know parent origin yet, so '*' is required. - // Guard at module scope so React strict mode's double-invoke doesn't - // re-post (and so a re-post can't race with the parent's init reply). - if (!readyPosted && window.parent && window.parent !== window) { + // Initial ping, targeted at the host origin so it can only land on the + // page that framed us. Guard at module scope so React strict mode's + // double-invoke doesn't re-post (and so a re-post can't race with the + // parent's init reply). + if (!readyPosted) { readyPosted = true; - window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*'); + sendToHost({ type: 'sandbox-ready' }); } return () => { window.removeEventListener('message', handleHostMessage); diff --git a/proxy.ts b/proxy.ts index 00006747a..def9b2ab6 100644 --- a/proxy.ts +++ b/proxy.ts @@ -182,6 +182,25 @@ export async function proxy(request: NextRequest) { pathname === "/plugin-sandbox-privileged" || pathname.startsWith("/plugin-sandbox-privileged/"); + // The sandbox routes are only ever loaded as iframes by the host bridge. + // Refuse them as a top-level document (window.open() from another site, a + // typed URL): the browser sets `Sec-Fetch-Dest` and page script cannot + // forge it; browsers without it fall through to the runtime's own host gate. + // They also disappear entirely while the plugin feature is off, so a stock + // install never serves a document carrying 'unsafe-eval' + // (GHSA-96cx-gx36-3g79). Other destinations pass - `iframe` for the bridge, + // `empty` for the router's own RSC refetches - and cross-site framing is + // already blocked by the `frame-ancestors 'self'` below. + if (isSandboxPath) { + const pluginsEnabled = configManager.getPolicy().features?.pluginsEnabled === true; + if (!pluginsEnabled || request.headers.get("sec-fetch-dest") === "document") { + return new NextResponse("The plugin sandbox is only served inside the webmail.", { + status: 403, + headers: { "content-type": "text/plain" }, + }); + } + } + const scriptSrc = isSandboxPath ? `'self' 'nonce-${nonce}' 'unsafe-eval'` : isDev