mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Every plugin iframe loaded /plugin-sandbox, a force-dynamic Next page. Each frame then fetched, parsed and hydrated Next's client router on top of React and the runtime, and the server rendered the page once per frame, one after another when a dozen plugins started together. Sandboxed frames have opaque origins, so the browser's code cache never helps, and they run on the host page's main thread: with 12 plugins the frames took 2.5-3 s to come up after the app had loaded. The sandbox routes now return a small HTML page with the UI kit's stylesheet and the runtime as an ES module, both built ahead of time: - `scripts/build-plugin-sandbox.mjs` bundles the runtime with esbuild and compiles `plugin-kit.css` with the app's Tailwind setup into `public/plugin-sandbox-assets/`, with a manifest. Named Tabler imports are pointed at the icons' own files, so the full icon set stays in a chunk that only loads for an icon name the kit doesn't ship. - `npm run build`, `npm run dev`, the Dockerfile and the perf-guard workflow run it before `next build`. - `lib/plugin-sandbox/sandbox-document.ts` writes the page; proxy.ts still sets the sandbox CSP and the script carries its nonce. - The assets are served from public/ with CORS and immutable caching. A frame on an idle page is ready in 255 ms instead of 362 ms, eleven at once in 1.0 s instead of 2.1 s.
205 lines
8.9 KiB
TypeScript
205 lines
8.9 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*
|
|
* The proxy used to treat every path whose last segment contains a dot as a
|
|
* static asset and return before setting the CSP, X-Content-Type-Options and
|
|
* the other security headers. This app routes [[...segments]] catch-alls
|
|
* under /<locale>/mail, /calendar, /contacts and /files, so
|
|
* /en/mail/folder/inbox/statement.pdf is a real, signed-in page - and it was
|
|
* served without a CSP, which let a sender-typed blob: URL opened from that
|
|
* document execute in the webmail origin (GHSA-xvjh-v9c6-qcvc). Only what
|
|
* Next genuinely serves from public/ may skip the headers now.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
|
|
vi.mock('@/lib/admin/config-manager', () => ({
|
|
configManager: {
|
|
ensureLoaded: vi.fn(async () => {}),
|
|
get: vi.fn((_key: string, fallback: unknown) => fallback),
|
|
getPolicy: vi.fn(() => ({ features: { pluginsEnabled: false } })),
|
|
},
|
|
}));
|
|
vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') }));
|
|
vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) }));
|
|
vi.mock('next-intl/middleware', async () => {
|
|
const { NextResponse } = await import('next/server');
|
|
return { default: () => () => NextResponse.next() };
|
|
});
|
|
|
|
import { proxy, isStaticAssetPath, mailFrameAncestors } from '@/proxy';
|
|
|
|
function load(path: string): Promise<Response> {
|
|
return proxy(new NextRequest('http://localhost:3000' + path, {
|
|
headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' },
|
|
}));
|
|
}
|
|
|
|
const SECURITY_HEADERS = ['content-security-policy', 'x-content-type-options', 'x-frame-options', 'referrer-policy', 'permissions-policy'];
|
|
|
|
// Signed-in pages whose last segment happens to contain a dot.
|
|
const DOTTED_APP_PATHS = [
|
|
'/en/mail/folder/inbox/statement.pdf',
|
|
'/de/mail/folder/inbox/statement.pdf',
|
|
'/en/calendar/2026/09/17.ics',
|
|
'/en/contacts/jan.kahmen',
|
|
'/en/files/reports/q3.xlsx',
|
|
'/en/settings/account.json',
|
|
'/mail/folder/inbox/statement.pdf',
|
|
'/en/.well-known/security.txt',
|
|
];
|
|
|
|
// What public/ (and Next's own route handlers at the root) actually serve.
|
|
const STATIC_ASSETS = [
|
|
'/sw.js',
|
|
'/favicon.ico',
|
|
'/manifest.webmanifest',
|
|
'/icon-192x192.png',
|
|
'/branding/logo.png',
|
|
'/notification/cheerful-527.mp3',
|
|
'/demo/prototype-v3.png',
|
|
// The plugin sandbox document's script (scripts/build-plugin-sandbox.mjs).
|
|
'/plugin-sandbox-assets/runtime-ABC123.js',
|
|
];
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('security headers on dotted app paths (GHSA-xvjh-v9c6-qcvc)', () => {
|
|
it.each(DOTTED_APP_PATHS)('serves %s with the full security header set', async (path) => {
|
|
const response = await load(path);
|
|
|
|
expect(response.status).toBe(200);
|
|
for (const header of SECURITY_HEADERS) {
|
|
expect(response.headers.get(header), header).not.toBeNull();
|
|
}
|
|
const csp = response.headers.get('content-security-policy') ?? '';
|
|
expect(csp).toMatch(/script-src 'self' 'nonce-[^']+'/);
|
|
expect(csp).not.toContain("'unsafe-eval'");
|
|
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
|
// The nonce reaches the layout the same way it does on /en/mail/folder/inbox.
|
|
expect(response.headers.get('x-middleware-request-x-nonce')).toBeTruthy();
|
|
expect(response.headers.get('x-middleware-request-x-pathname')).toBe(path);
|
|
});
|
|
|
|
it('gives a dotted app path the same policy as its canonical sibling', async () => {
|
|
const dotted = await load('/en/mail/folder/inbox/statement.pdf');
|
|
const canonical = await load('/en/mail/folder/inbox');
|
|
|
|
const strip = (csp: string | null) => (csp ?? '').replace(/'nonce-[^']+'/g, "'nonce-X'");
|
|
expect(strip(dotted.headers.get('content-security-policy'))).toBe(strip(canonical.headers.get('content-security-policy')));
|
|
for (const header of SECURITY_HEADERS.filter((h) => h !== 'content-security-policy')) {
|
|
expect(dotted.headers.get(header)).toBe(canonical.headers.get(header));
|
|
}
|
|
});
|
|
|
|
it.each(STATIC_ASSETS)('still passes %s straight through untouched', async (path) => {
|
|
const response = await load(path);
|
|
|
|
expect(response.status).toBe(200);
|
|
// No page CSP (frame-ancestors alone restricts nothing a script or
|
|
// image does) and no locale routing.
|
|
expect(response.headers.get('content-security-policy')).toBe("frame-ancestors 'none'");
|
|
expect(response.headers.get('x-middleware-request-x-nonce')).toBeNull();
|
|
});
|
|
|
|
it.each(['/api/auth/session', '/_next/static/chunks/main.js', '/_next/image'])('still skips %s', async (path) => {
|
|
const response = await load(path);
|
|
|
|
expect(response.headers.get('content-security-policy') ?? '').not.toContain('script-src');
|
|
});
|
|
|
|
it.each(['/missing.html', '/branding/nothing-here', '/_next/static/x.js'])(
|
|
'keeps %s from being framed or sniffed',
|
|
async (path) => {
|
|
const response = await load(path);
|
|
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
|
expect(response.headers.get('x-frame-options')).toBe('DENY');
|
|
expect(response.headers.get('content-security-policy')).toBe("frame-ancestors 'none'");
|
|
},
|
|
);
|
|
|
|
it('leaves the CSP of API routes to the route', async () => {
|
|
const response = await load('/api/unknown');
|
|
expect(response.headers.get('x-frame-options')).toBe('DENY');
|
|
expect(response.headers.get('content-security-policy')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ALLOWED_FRAME_ANCESTORS', () => {
|
|
const original = process.env.ALLOWED_FRAME_ANCESTORS;
|
|
beforeEach(() => {
|
|
process.env.ALLOWED_FRAME_ANCESTORS = 'https://portal.example';
|
|
});
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.ALLOWED_FRAME_ANCESTORS;
|
|
else process.env.ALLOWED_FRAME_ANCESTORS = original;
|
|
});
|
|
|
|
it('sends HSTS only on a request that arrived over TLS', async () => {
|
|
const plain = await load('/en/mail');
|
|
expect(plain.headers.get('strict-transport-security')).toBeNull();
|
|
const forwarded = await proxy(new NextRequest('http://localhost:3000/en/mail', {
|
|
headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none', 'x-forwarded-proto': 'https' },
|
|
}));
|
|
expect(forwarded.headers.get('strict-transport-security')).toBe('max-age=63072000');
|
|
const direct = await proxy(new NextRequest('https://webmail.example/en/mail', {
|
|
headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' },
|
|
}));
|
|
expect(direct.headers.get('strict-transport-security')).toBe('max-age=63072000');
|
|
});
|
|
|
|
it('lets a portal frame the mail UI', async () => {
|
|
const res = await load('/en/mail');
|
|
expect(res.headers.get('content-security-policy')).toContain("frame-ancestors 'self' https://portal.example;");
|
|
expect(res.headers.get('x-frame-options')).toBeNull();
|
|
});
|
|
|
|
it.each(['/admin', '/admin/login', '/admin/settings'])('never lets anyone frame %s', async (path) => {
|
|
const res = await load(path);
|
|
expect(res.headers.get('content-security-policy')).toContain("frame-ancestors 'none'");
|
|
expect(res.headers.get('x-frame-options')).toBe('DENY');
|
|
});
|
|
});
|
|
|
|
// The PDF preview frames a blob: document, and Safari enforces the page's
|
|
// frame-ancestors on it, so the mail UI must always allow 'self' (#1301).
|
|
describe('frame-ancestors of the mail UI', () => {
|
|
const original = process.env.ALLOWED_FRAME_ANCESTORS;
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.ALLOWED_FRAME_ANCESTORS;
|
|
else process.env.ALLOWED_FRAME_ANCESTORS = original;
|
|
});
|
|
|
|
it('allows only the app itself by default', async () => {
|
|
delete process.env.ALLOWED_FRAME_ANCESTORS;
|
|
const res = await load('/en/mail/folder/inbox');
|
|
expect(res.headers.get('content-security-policy')).toContain("frame-ancestors 'self';");
|
|
expect(res.headers.get('x-frame-options')).toBe('SAMEORIGIN');
|
|
});
|
|
|
|
it("reads 'none' as no other site", async () => {
|
|
process.env.ALLOWED_FRAME_ANCESTORS = "'none'";
|
|
const res = await load('/en/mail/folder/inbox');
|
|
expect(res.headers.get('content-security-policy')).toContain("frame-ancestors 'self';");
|
|
});
|
|
|
|
it("does not repeat 'self' the operator already listed", () => {
|
|
expect(mailFrameAncestors(" https://a.example 'SELF' ")).toBe("https://a.example 'SELF'");
|
|
expect(mailFrameAncestors('')).toBe("'self'");
|
|
});
|
|
});
|
|
|
|
describe('isStaticAssetPath', () => {
|
|
it('matches only root-level files and the public/ asset folders', () => {
|
|
for (const path of STATIC_ASSETS) expect(isStaticAssetPath(path), path).toBe(true);
|
|
for (const path of DOTTED_APP_PATHS) expect(isStaticAssetPath(path), path).toBe(false);
|
|
expect(isStaticAssetPath('/en')).toBe(false);
|
|
expect(isStaticAssetPath('/en/mail')).toBe(false);
|
|
expect(isStaticAssetPath('/brandingx/logo.png')).toBe(false);
|
|
// The sandbox documents themselves keep the sandbox CSP.
|
|
expect(isStaticAssetPath('/plugin-sandbox')).toBe(false);
|
|
});
|
|
});
|