mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
102 lines
4.2 KiB
TypeScript
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'");
|
|
});
|
|
});
|