mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge remote-tracking branch 'origin/main' into feat/desktop-offline
This commit is contained in:
@@ -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' });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", ""));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user