fix: bind the plugin sandbox to its framing window (GHSA-96cx-gx36-3g79)

This commit is contained in:
Linus Rath
2026-09-17 20:31:39 +02:00
parent b7f53a3a8b
commit d5c0996519
4 changed files with 317 additions and 15 deletions
@@ -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<string, string> = {}): Promise<Response> {
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'");
});
});
@@ -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<typeof vi.fn> };
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<void> {
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],
]);
});
});
+53 -15
View File
@@ -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<void> {
// ─── 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);
+19
View File
@@ -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