Files
webmail/lib/__tests__/plugin-sandbox-route-gate.test.ts
T

102 lines
4.2 KiB
TypeScript

/**
* @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'");
});
});