From 3b9fed9b65a58327d3878360dd798e2615fc7e5e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:33:53 +0100 Subject: [PATCH] fix: load plugins when the webmail is framed by an allowed portal #1533 --- lib/__tests__/plugin-sandbox-route-gate.test.ts | 15 ++++++++++++++- lib/plugin-sandbox/runtime.tsx | 6 ++++-- proxy.ts | 16 ++++++++++------ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/__tests__/plugin-sandbox-route-gate.test.ts b/lib/__tests__/plugin-sandbox-route-gate.test.ts index 4da191ba2..64e2dda3e 100644 --- a/lib/__tests__/plugin-sandbox-route-gate.test.ts +++ b/lib/__tests__/plugin-sandbox-route-gate.test.ts @@ -13,10 +13,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; let pluginsEnabled = true; +let allowedFrameAncestors = ''; vi.mock('@/lib/admin/config-manager', () => ({ configManager: { ensureLoaded: vi.fn(async () => {}), - get: vi.fn((_key: string, fallback: unknown) => fallback), + get: vi.fn((key: string, fallback: unknown) => (key === 'allowedFrameAncestors' ? allowedFrameAncestors : fallback)), getPolicy: vi.fn(() => ({ features: { pluginsEnabled } })), }, })); @@ -41,6 +42,7 @@ const IFRAME_LOAD = { 'sec-fetch-dest': 'iframe', 'sec-fetch-site': 'same-origin beforeEach(() => { vi.clearAllMocks(); pluginsEnabled = true; + allowedFrameAncestors = ''; }); describe('plugin sandbox routes (GHSA-96cx-gx36-3g79)', () => { @@ -53,6 +55,17 @@ describe('plugin sandbox routes (GHSA-96cx-gx36-3g79)', () => { expect(csp).toContain("frame-ancestors 'self'"); }); + // frame-ancestors is checked against every ancestor, so a portal allowed to + // frame the mail UI must be allowed on the plugin frames inside it too (#1533). + it.each(SANDBOX_ROUTES)('lets %s load inside a mail UI framed by an allowed portal', async (path) => { + allowedFrameAncestors = 'https://portal.example'; + const response = await load(path, IFRAME_LOAD); + + expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'self' https://portal.example;"); + // X-Frame-Options can't name the portal and would block it in browsers that read it. + expect(response.headers.get('x-frame-options')).toBeNull(); + }); + 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' }); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 894e73da4..11edf5d3e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -291,8 +291,10 @@ function openActivityPort(): MessagePort | null { * is never a host. * - `location.origin` is the URL origin even when this iframe runs with an * opaque origin (sandbox="allow-scripts"), and the host page is served - * from that same origin: the bridge loads a same-origin path and the route - * CSP pins `frame-ancestors 'self'`. + * from that same origin: the bridge loads a same-origin path. The route + * CSP's frame-ancestors also admits the portals the operator lets frame + * the mail UI (#1533), so the origin check on every message is what keeps + * a portal that frames this route directly from driving it. * * Pinning the first sender instead (pre-fix) let any website window.open() * the route, post its own `init` before a real host could, and run its code diff --git a/proxy.ts b/proxy.ts index 92c1719e7..e3ccc76ec 100644 --- a/proxy.ts +++ b/proxy.ts @@ -294,7 +294,7 @@ export async function proxy(request: NextRequest) { // bundles via `new Function`. The untrusted route is null-origin // (sandbox="allow-scripts"); the privileged route is same-origin // (allow-same-origin) so a vetted plugin gets real WebCrypto + IndexedDB. - // Both get the SAME CSP relaxations (unsafe-eval, frame-ancestors 'self'); + // Both get the SAME CSP relaxations (unsafe-eval); // the privileged route's extra power comes from the iframe sandbox flag the // host sets, gated by signature + admin approval, NOT from a wider CSP. const isSandboxPath = @@ -310,8 +310,8 @@ export async function proxy(request: NextRequest) { // They also disappear entirely while the plugin feature is off, so a stock // install never serves a document carrying 'unsafe-eval' // (GHSA-96cx-gx36-3g79). Other destinations pass - `iframe` for the bridge, - // `empty` for the router's own RSC refetches - and cross-site framing is - // already blocked by the `frame-ancestors 'self'` below. + // `empty` for the router's own RSC refetches - and framing by any site + // outside the mail UI's frame-ancestors is blocked by the CSP below. if (isSandboxPath) { const pluginsEnabled = configManager.getPolicy().features?.pluginsEnabled === true; if (!pluginsEnabled || request.headers.get("sec-fetch-dest") === "document") { @@ -360,10 +360,14 @@ export async function proxy(request: NextRequest) { // The admin dashboard and the setup wizard are never framed. The embedding // allowance is for the mail UI a portal wraps; a framed admin page is a // clickjacking target with nothing to gain from being embedded. + // + // The sandbox routes get the mail UI's list. The browser checks + // frame-ancestors against every ancestor, not just the parent, so with + // 'self' alone a portal that frames the mail UI blocks every plugin frame + // inside it (#1533). The parent is still the webmail itself: the runtime + // only takes messages from its parent posting from its own origin. const isAdminOrSetupPath = /^\/(?:admin|setup)(?:\/|$)/.test(pathname); - const frameAncestors = isSandboxPath - ? `'self'` - : isAdminOrSetupPath + const frameAncestors = isAdminOrSetupPath ? "'none'" : mailFrameAncestors(configManager.get("allowedFrameAncestors", ""));