mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
/**
|
|
* 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],
|
|
]);
|
|
});
|
|
});
|