@@ -149,16 +149,17 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
{on ? 'Still reachable' : 'Stays reachable with the switch on'}
-
Hosts you configured yourself.
+
Hosts you configured yourself, and what users enter themselves.
{state.reachable.length === 0 ? (
None configured.
) : (
{state.reachable.map((h) => (
- -
+
-
{h.kind}
- {h.host}
+ {h.host}
{h.setIn} · {SIDE_LABEL[h.side]}
+ {h.note && {h.note}}
))}
diff --git a/components/ui/__tests__/avatar-sample-photos.test.tsx b/components/ui/__tests__/avatar-sample-photos.test.tsx
new file mode 100644
index 000000000..a27973a12
--- /dev/null
+++ b/components/ui/__tests__/avatar-sample-photos.test.tsx
@@ -0,0 +1,61 @@
+import { render, waitFor } from '@testing-library/react';
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
+
+// #903: the mock server's sample photos come from randomuser.me and
+// img.freepik.com; none of them while third-party traffic is off.
+
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ devMode: true }) }));
+
+import { Avatar } from '../avatar';
+import { avatarHooks } from '@/lib/plugin-hooks';
+
+const PEOPLE = ['anna', 'ben', 'carla', 'dirk', 'eva', 'finn', 'greta', 'hans'].map((n) => `${n}@gmail.com`);
+
+function sampleImages(): string[] {
+ const { container, unmount } = render(
+
+ {PEOPLE.map((email) =>
)}
+
+
,
+ );
+ const srcs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src') ?? '');
+ unmount();
+ return srcs.filter((src) => /randomuser\.me|img\.freepik\.com/.test(src));
+}
+
+afterEach(() => setNoThirdPartyTraffic(false));
+
+describe('mock server sample photos', () => {
+ it('show with third-party traffic allowed', () => {
+ expect(sampleImages().length).toBeGreaterThan(0);
+ });
+
+ it('are left out with no third-party traffic', () => {
+ setNoThirdPartyTraffic(true);
+ expect(sampleImages()).toEqual([]);
+ });
+});
+
+describe('plugin avatars (Gravatar and the like)', () => {
+ afterEach(() => avatarHooks.onAvatarResolve.clear());
+
+ async function renderWithGravatar() {
+ avatarHooks.onAvatarResolve.register('gravatar', (async () => 'https://www.gravatar.com/avatar/abc?d=404') as never);
+ const view = render(
);
+ await new Promise((r) => setTimeout(r, 20));
+ return view;
+ }
+
+ it("show the plugin's outside URL with third-party traffic allowed", async () => {
+ const { container } = await renderWithGravatar();
+ await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toMatch(/gravatar\.com/));
+ });
+
+ it('fall back to initials with no third-party traffic', async () => {
+ setNoThirdPartyTraffic(true);
+ const { container } = await renderWithGravatar();
+ expect(container.querySelector('img')).toBeNull();
+ expect(container.textContent).toBe('ZO');
+ });
+});
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
index 40f311cdb..96db820ae 100644
--- a/components/ui/avatar.tsx
+++ b/components/ui/avatar.tsx
@@ -9,7 +9,8 @@ import { useContactPhotoSrc } from "@/hooks/use-contact-photo";
import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
import { IS_LITE } from "@/lib/lite";
-import { sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts";
+import { avatarUrlAllowed, sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts";
+import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -166,6 +167,8 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
const faviconRefreshedAt = useFaviconRefreshStore((s) => s.refreshedAt);
const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig();
+ // Re-render when the config arrives: until then nothing outside is asked (#903).
+ useNoThirdPartyTraffic();
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
// Runs whenever email or name changes; resets plugin avatar state on each change.
@@ -188,7 +191,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
return getContactPhotoIndex(contacts).get(email.toLowerCase()) ?? null;
}, [disableImages, contactPhoto, email, contacts]);
const { src: contactPhotoSrc, observe } = useContactPhotoSrc(photoRef);
- const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto ? contactPhotoSrc : undefined;
+ const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto && avatarUrlAllowed(contactPhotoSrc) ? contactPhotoSrc : undefined;
// A refresh of the sender icons gives a failed favicon another chance.
useEffect(() => {
@@ -245,7 +248,8 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email && sampleAvatarsAllowed() ? CUSTOM_AVATARS[email.toLowerCase()] : null;
- const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
+ // Gravatar and the like are outside hosts: initials instead in the no-third-party mode.
+ const pluginAvatar = pluginAvatarFailed || !avatarUrlAllowed(pluginAvatarUrl) ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null;
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
diff --git a/lib/__tests__/no-third-party-server.test.ts b/lib/__tests__/no-third-party-server.test.ts
index fe13ce1be..16c7b136a 100644
--- a/lib/__tests__/no-third-party-server.test.ts
+++ b/lib/__tests__/no-third-party-server.test.ts
@@ -284,7 +284,7 @@ describe('admin view', () => {
await configManager.load();
const body = await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json();
expect(body).toMatchObject({ noThirdPartyTraffic: true, source: 'admin', envLocked: false, readOnly: false });
- expect(body.reachable.map((h: { host: string }) => h.host)).toEqual(['mail.corp.example', 'push.corp.example']);
+ expect(body.reachable.map((h: { host: string }) => h.host)).toEqual(['mail.corp.example', 'push.corp.example', 'any address your users enter', 'any address your users enter']);
expect(body.networkPlugins).toEqual([{ id: 'fetcher', name: 'Fetcher' }]);
expect(body.builtIn.length).toBeGreaterThan(5);
@@ -292,3 +292,69 @@ describe('admin view', () => {
expect(await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json()).toMatchObject({ source: 'env', envLocked: true });
});
});
+
+describe('review follow-ups', () => {
+ it('counts a string "true", "1", "yes" or "on" in config.json as on', async () => {
+ for (const value of ['true', '1', 'yes', 'on', 'TRUE']) {
+ writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: value }));
+ await configManager.load();
+ expect(await isNoThirdPartyTrafficOn(), value).toBe(true);
+ expect(noThirdPartyTrafficState(), value).toEqual({ on: true, source: 'admin' });
+ }
+ writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: 'no' }));
+ await configManager.load();
+ expect(await isNoThirdPartyTrafficOn()).toBe(false);
+ });
+
+ it('keeps the version check scheduled in the mode, so it resumes once the switch is off', async () => {
+ const { startScheduler, stopScheduler, checkOnce, loadState, saveState } = await import('@/lib/version-check');
+ fetchMock.mockImplementation(async () => Response.json({ latest: '1.18.0', severity: 'none' }));
+ try {
+ await setMode(true);
+ await saveState({ ...(await loadState()), nextScheduledAt: null });
+ await startScheduler();
+ expect((await loadState()).nextScheduledAt).not.toBeNull();
+ await stopScheduler();
+ expect((await checkOnce()).ok).toBe(false);
+ expect(fetchMock).not.toHaveBeenCalled();
+ await setMode(false);
+ await checkOnce();
+ expect(calledHosts()).toEqual(['version.telemetry.bulwarkmail.org']);
+ } finally {
+ await stopScheduler();
+ fetchMock.mockImplementation(async () => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
+ }
+ });
+
+ it('makes getDirectoryUrl refuse the official directory in the mode', async () => {
+ const { getDirectoryUrl, DirectoryError } = await import('@/lib/admin/marketplace-directory');
+ await setMode(true);
+ await expect(getDirectoryUrl()).rejects.toBeInstanceOf(DirectoryError);
+ await expect(getDirectoryUrl()).rejects.toMatchObject({ status: 403 });
+ await setMode(false);
+ expect(await getDirectoryUrl()).toBe('https://extensions.bulwarkmail.org');
+ });
+
+ it('leaves the sample contact photos out of the mock server in the mode', async () => {
+ process.env.DEV_MOCK_JMAP = 'true';
+ process.env.NEXT_PUBLIC_DEV_MOCK_JMAP = 'true';
+ try {
+ const { POST } = await import('@/app/api/dev-jmap/[...path]/route');
+ const photos = async () => {
+ const res = await POST(
+ new NextRequest('http://localhost:3000/api/dev-jmap/api', { method: 'POST', body: JSON.stringify({ using: [], methodCalls: [['ContactCard/get', {}, '0']] }) }),
+ { params: Promise.resolve({ path: ['api'] }) },
+ );
+ const list = (await res.json()).methodResponses[0][1].list as Array<{ media?: Record
}>;
+ return list.flatMap((c) => Object.values(c.media ?? {}).map((m) => m.uri ?? '')).filter((uri) => uri.includes('randomuser.me'));
+ };
+ await setMode(false);
+ expect((await photos()).length).toBeGreaterThan(0);
+ await setMode(true);
+ expect(await photos()).toEqual([]);
+ } finally {
+ delete process.env.DEV_MOCK_JMAP;
+ delete process.env.NEXT_PUBLIC_DEV_MOCK_JMAP;
+ }
+ });
+});
diff --git a/lib/__tests__/proxy-no-third-party-csp.test.ts b/lib/__tests__/proxy-no-third-party-csp.test.ts
new file mode 100644
index 000000000..0a7b29172
--- /dev/null
+++ b/lib/__tests__/proxy-no-third-party-csp.test.ts
@@ -0,0 +1,69 @@
+/**
+ * @vitest-environment node
+ *
+ * With no third-party traffic (#903) the page's CSP lists only its own origin
+ * and the hosts the deployment configured, so anything the in-app switches
+ * miss (a privileged plugin, a Gravatar URL) is still stopped by the browser.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+
+const config: Record = {};
+let policy: Record = {};
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: vi.fn(async () => {}),
+ get: vi.fn((key: string, fallback: unknown) => (key in config ? config[key] : fallback)),
+ getPolicy: vi.fn(() => policy),
+ },
+}));
+vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') }));
+vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) }));
+vi.mock('@/lib/wopi/discovery', () => ({ getWopiEditorOrigins: vi.fn(async () => []) }));
+vi.mock('next-intl/middleware', async () => {
+ const { NextResponse } = await import('next/server');
+ return { default: () => () => NextResponse.next() };
+});
+
+import { proxy } from '@/proxy';
+
+async function csp(): Promise> {
+ const res = await proxy(new NextRequest('http://localhost:3000/en/mail', { headers: { 'sec-fetch-dest': 'document' } }));
+ const header = res.headers.get('content-security-policy') ?? '';
+ return Object.fromEntries(header.split('; ').map((d) => [d.split(' ')[0], d.slice(d.indexOf(' ') + 1)]));
+}
+
+beforeEach(() => {
+ for (const key of Object.keys(config)) delete config[key];
+ policy = { features: { pluginsEnabled: false }, pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }], defaultSidebarApps: [] };
+ config.jmapServerUrl = 'https://mail.corp.example';
+});
+
+describe('page CSP with no third-party traffic', () => {
+ it('lists only the own origin and the configured hosts', async () => {
+ config.noThirdPartyTraffic = true;
+ const d = await csp();
+ expect(d['connect-src']).toBe("'self' https://mail.corp.example https://push.corp.example");
+ expect(d['img-src']).toBe("'self' data: blob: https://mail.corp.example https://push.corp.example");
+ expect(d['font-src']).toBe("'self' data: https://mail.corp.example https://push.corp.example");
+ expect(d['media-src']).toBe("'self' blob: https://mail.corp.example https://push.corp.example");
+ expect(d['frame-src']).toContain('https://mail.corp.example');
+ for (const directive of ['connect-src', 'img-src', 'font-src', 'media-src', 'frame-src']) {
+ expect(d[directive], directive).not.toMatch(/(^| )https?:( |$)|gravatar|bulwarkmail\.org/);
+ }
+ });
+
+ it('keeps https: in connect-src only when users may sign in to any server', async () => {
+ config.noThirdPartyTraffic = true;
+ config.allowCustomJmapEndpoint = true;
+ const d = await csp();
+ expect(d['connect-src']).toMatch(/ https:( |$)/);
+ expect(d['img-src']).not.toMatch(/ https:( |$)/);
+ });
+
+ it('stays as before with the mode off', async () => {
+ const d = await csp();
+ expect(d['img-src']).toBe("'self' data: blob: https:");
+ expect(d['connect-src']).toContain('https:');
+ });
+});
diff --git a/lib/admin/reachable-hosts.ts b/lib/admin/reachable-hosts.ts
index 40f1942c6..cfae6020d 100644
--- a/lib/admin/reachable-hosts.ts
+++ b/lib/admin/reachable-hosts.ts
@@ -1,30 +1,37 @@
import type { SettingsPolicy } from './types';
import { parseJmapServers } from './jmap-servers';
import { isBuiltInHost } from '@/lib/outgoing-hosts';
+import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
/**
* What stays reachable with "no third-party traffic" on: the hosts the
- * deployment configured itself. Shown on Organisation > Security so an admin
- * can check the list against what they expect (#903).
+ * deployment configured itself, plus the addresses users type in themselves.
+ * Shown on Organisation > Security so an admin can check the list against
+ * what they expect, and turned into the page's CSP in the mode (#903).
*/
export interface ReachableHost {
/** What the host is for, e.g. "JMAP server". */
kind: string;
- /** The host (and port when it isn't the default). */
+ /** The host (and port when it isn't the default), or "any address your users enter". */
host: string;
+ /** scheme://host[:port], for the CSP; absent for addresses users enter. */
+ origin?: string;
/** Where it was configured, e.g. "JMAP_SERVER_URL" or "User policy > Push relays". */
setIn: string;
/** Who connects: this server, the browser, or both. */
side: 'server' | 'browser' | 'both';
+ /** Something the admin should know about this line. */
+ note?: string;
}
-function hostOf(url: unknown): string | null {
+export const USER_ENTERED = 'any address your users enter';
+
+function parse(url: unknown): URL | null {
if (typeof url !== 'string' || !url.trim()) return null;
try {
const parsed = new URL(url.trim());
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
- return parsed.host.toLowerCase();
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed : null;
} catch {
return null;
}
@@ -32,8 +39,10 @@ function hostOf(url: unknown): string | null {
export interface ReachableInput {
get: (key: string) => unknown;
- policy: Pick;
+ policy: Pick & { features?: Partial };
env: Record;
+ /** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */
+ buildRelayUrl?: string;
}
const BRANDING_KEYS = [
@@ -41,18 +50,19 @@ const BRANDING_KEYS = [
'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl',
] as const;
-export function listReachableHosts({ get, policy, env }: ReachableInput): ReachableHost[] {
+export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_RELAY_BASE_URL }: ReachableInput): ReachableHost[] {
const out: ReachableHost[] = [];
const seen = new Set();
- const add = (kind: string, url: unknown, setIn: string, side: ReachableHost['side']) => {
- const host = hostOf(url);
+ const add = (kind: string, url: unknown, setIn: string, side: ReachableHost['side'], note?: string) => {
+ const parsed = parse(url);
// A built-in host listed here would be off in the mode anyway; leaving
// it out keeps the list to what really stays reachable.
- if (!host || isBuiltInHost(String(url))) return;
+ if (!parsed || isBuiltInHost(parsed.href)) return;
+ const host = parsed.host.toLowerCase();
const key = `${kind}|${host}`;
if (seen.has(key)) return;
seen.add(key);
- out.push({ kind, host, setIn, side });
+ out.push({ kind, host, origin: parsed.origin, setIn, side, ...(note ? { note } : {}) });
};
add('JMAP server', get('jmapServerUrl'), 'JMAP_SERVER_URL', 'both');
@@ -68,11 +78,45 @@ export function listReachableHosts({ get, policy, env }: ReachableInput): Reacha
}
for (const relay of policy.pushRelays ?? []) add('Push relay', relay?.url, 'User policy > Push relays', 'browser');
add('Push relay', policy.pushRelayUrl, 'User policy > Push relays', 'browser');
- add('Push relay', env.NEXT_PUBLIC_PUSH_RELAY_URL, 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', 'browser');
+ // The relay browsers offer as the default is the one inlined when the
+ // bundle was built. A different value in the running environment does
+ // nothing for them, so it is listed with a warning.
+ add('Push relay', buildRelayUrl, 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', 'browser');
+ const runtimeRelay = parse(env.NEXT_PUBLIC_PUSH_RELAY_URL);
+ if (runtimeRelay && runtimeRelay.origin !== parse(buildRelayUrl)?.origin) {
+ add('Push relay', runtimeRelay.href, 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', 'browser',
+ 'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under User policy > Push relays.');
+ }
add('Extension directory', get('extensionDirectoryUrl'), 'EXTENSION_DIRECTORY_URL', 'server');
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
for (const key of BRANDING_KEYS) add('Branding image', get(key), key, 'browser');
+
+ // User-directed: what users type in themselves stays allowed.
+ if (get('allowCustomJmapEndpoint') === true) {
+ out.push({ kind: 'JMAP server', host: USER_ENTERED, setIn: 'ALLOW_CUSTOM_JMAP_ENDPOINT', side: 'both',
+ note: 'Users can sign in to any server, so the browser may connect to any https host.' });
+ }
+ if (policy.features?.sidebarAppsEnabled !== false) {
+ out.push({ kind: 'Sidebar apps users add', host: USER_ENTERED, setIn: 'Users, in Settings > Apps', side: 'browser' });
+ }
+ if (policy.features?.calendarEnabled !== false) {
+ out.push({ kind: 'Calendar subscriptions', host: USER_ENTERED, setIn: 'Users, in Calendar', side: 'server' });
+ }
return out;
}
+
+/**
+ * The Content-Security-Policy sources of the page in the no-third-party mode:
+ * 'self' and the origins above. With custom JMAP endpoints allowed, the
+ * browser must reach whatever server a user signs in to, so connect-src
+ * keeps https: (the other directives stay narrow).
+ */
+export function noThirdPartyCspSources(hosts: ReachableHost[], allowCustomJmapEndpoint: boolean): { connect: string[]; other: string[] } {
+ const origins = [...new Set(hosts.flatMap((h) => (h.origin ? [h.origin] : [])))];
+ return {
+ connect: allowCustomJmapEndpoint ? [...origins, 'https:'] : origins,
+ other: origins,
+ };
+}
diff --git a/proxy.ts b/proxy.ts
index a9cce10c4..fe15cd24b 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -10,6 +10,8 @@ import {
pickAppFrameOriginsCookie,
} from "./lib/security/app-frame-origins";
import { configManager } from "./lib/admin/config-manager";
+import { isNoThirdPartyTrafficOn } from "./lib/admin/third-party-mode";
+import { listReachableHosts, noThirdPartyCspSources } from "./lib/admin/reachable-hosts";
import { getWopiEditorOrigins } from "./lib/wopi/discovery";
import { detectSetupState } from "./lib/setup/state";
import { isServedOverHttps } from "./lib/security/request-protocol";
@@ -298,11 +300,29 @@ export async function proxy(request: NextRequest) {
// itself gets its own origin and nothing else, so fetch(), images, fonts
// and CSS url() can't carry data to a host the manifest didn't name. Its
// own origin stays because the router refetches the page as RSC.
+ //
+ // With no third-party traffic (#903) the page itself gets the same kind of
+ // fence: its own origin plus the hosts the deployment configured (JMAP
+ // servers, OAuth issuer, relays, a self-hosted directory, sidebar apps,
+ // WOPI, LibreTranslate, branding images). It catches whatever the in-app
+ // switches miss, privileged plugins and plugin avatar URLs included.
+ const noThirdParty = !isSandboxPath && await isNoThirdPartyTrafficOn();
+ const ownHosts = noThirdParty
+ ? noThirdPartyCspSources(
+ listReachableHosts({ get: (key) => configManager.get(key), policy: configManager.getPolicy(), env: process.env }),
+ configManager.get("allowCustomJmapEndpoint", false) === true,
+ )
+ : null;
+ const own = (sources: string[]) => (sources.length > 0 ? ` ${sources.join(" ")}` : "");
const connectSrc = isSandboxPath
? `'self'`
+ : ownHosts ? `'self'${own(ownHosts.connect)}`
: isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
- const imgSrc = isSandboxPath ? `'self' data: blob:` : `'self' data: blob: https:`;
- const fontSrc = isSandboxPath ? `'self' data:` : `'self' https: data:`;
+ const imgSrc = isSandboxPath ? `'self' data: blob:`
+ : ownHosts ? `'self' data: blob:${own(ownHosts.other)}` : `'self' data: blob: https:`;
+ const fontSrc = isSandboxPath ? `'self' data:`
+ : ownHosts ? `'self' data:${own(ownHosts.other)}` : `'self' https: data:`;
+ const mediaSrc = ownHosts ? `'self' blob:${own(ownHosts.other)}` : `'self' blob:`;
// 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
@@ -349,7 +369,7 @@ export async function proxy(request: NextRequest) {
const frameOrigins: string[] = [];
const seenFrameOrigins = new Set();
- for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...wopiOrigins]) {
+ for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...wopiOrigins, ...(ownHosts?.other ?? [])]) {
const key = origin.toLowerCase();
if (seenFrameOrigins.has(key)) continue;
seenFrameOrigins.add(key);
@@ -375,7 +395,7 @@ export async function proxy(request: NextRequest) {
// form must not post anywhere: that would be a way out past connect-src.
isSandboxPath ? `form-action 'none'` : `form-action ${["'self'", ...wopiOrigins].join(" ")}`,
`frame-ancestors ${frameAncestors}`,
- `media-src 'self' blob:`,
+ `media-src ${mediaSrc}`,
].join("; ");
// Skip intl middleware for routes outside the localized app tree.
From 010e9fb6d9474cfb095be66c9dab63396359ebde Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:30:42 +0100
Subject: [PATCH 25/54] feat: fence a Lite build made with no third-party
traffic to its own hosts #903
---
lib/__tests__/lite-scripts.test.ts | 13 ++++++++
scripts/lite/lib.mjs | 49 +++++++++++++++++++++++-------
scripts/lite/postbuild.mjs | 25 ++++++++++-----
3 files changed, 69 insertions(+), 18 deletions(-)
diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts
index e879ebb6f..a45b8ec88 100644
--- a/lib/__tests__/lite-scripts.test.ts
+++ b/lib/__tests__/lite-scripts.test.ts
@@ -194,6 +194,19 @@ describe('lite build helpers', () => {
expect(withScriptHashCsp('')).toMatch(/^]*>