fix: load plugins when the webmail is framed by an allowed portal #1533

This commit is contained in:
Linus Rath
2026-11-04 15:33:53 +01:00
parent c87c11734b
commit 3b9fed9b65
3 changed files with 28 additions and 9 deletions
@@ -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' });
+4 -2
View File
@@ -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
+10 -6
View File
@@ -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<string>("allowedFrameAncestors", ""));