mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: bind the plugin sandbox to its framing window (GHSA-96cx-gx36-3g79)
This commit is contained in:
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user