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