feat: limit the page's CSP to the configured hosts with no third-party traffic #903

This commit is contained in:
Linus Rath
2026-10-28 04:30:41 +01:00
parent 381494fe34
commit 1a747ac9dd
9 changed files with 314 additions and 26 deletions
+8 -1
View File
@@ -4,6 +4,7 @@ import { createDemoFileNodes } from '@/lib/demo/fixtures/files';
import { generateDemoId } from '@/lib/demo/demo-utils';
import type { FileNode } from '@/lib/jmap/types';
import JSZip from 'jszip';
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* Mock JMAP server for local development.
@@ -2035,9 +2036,14 @@ function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodRes
return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId];
}
// The sample photos come from randomuser.me: none while third-party traffic
// is off (#903). Set per request in POST, which can await the config.
let samplePhotos = true;
function handleContactCardGet(args: MethodArgs, callId: string): MethodResult {
const ids = args.ids as string[] | undefined;
const list = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
const found = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
const list = samplePhotos ? found : found.map(({ media: _media, ...rest }) => rest);
return ['ContactCard/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
}
@@ -2826,6 +2832,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// JMAP API endpoint
if (joined === 'api') {
samplePhotos = !(await isNoThirdPartyTrafficOn());
try {
const body = await request.json();
const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>;
@@ -108,6 +108,22 @@ describe('listReachableHosts', () => {
'Document editor (WOPI): office.corp.example',
'Translation (LibreTranslate): translate.corp.example',
'Branding image: cdn.corp.example',
'Sidebar apps users add: any address your users enter',
'Calendar subscriptions: any address your users enter',
]);
});
it('names the relay browsers were built with, flags a different one in the environment, and lists what users enter', () => {
const hosts = listReachableHosts({
get: (key) => ({ allowCustomJmapEndpoint: true } as Record<string, unknown>)[key],
policy: { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { calendarEnabled: false, sidebarAppsEnabled: false } },
env: { NEXT_PUBLIC_PUSH_RELAY_URL: 'https://push.runtime.example' },
buildRelayUrl: 'https://push.build.example',
});
expect(hosts.map((h) => [h.host, h.setIn, !!h.note])).toEqual([
['push.build.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', false],
['push.runtime.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', true],
['any address your users enter', 'ALLOW_CUSTOM_JMAP_ENDPOINT', true],
]);
});
});
@@ -119,7 +119,7 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
<>
<SettingItem
label="No third-party traffic"
description="Bulwark and your users' browsers stop contacting services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, sender icons, MyMemory translation and map links. Plugins that declare network access don't run; the others do."
description="Bulwark and your users' browsers stop contacting services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, the connector, sender icons, MyMemory translation, map links and OpenPGP key lookups at other domains. Plugins that declare network access don't run; the others do. The page's security policy then allows only the hosts listed below."
>
<div className="flex items-center gap-1.5" data-testid="third-party-switch">
<SourceBadge source={state.source} label={state.source === 'env' ? 'NO_THIRD_PARTY_TRAFFIC' : 'config.json'} />
@@ -149,16 +149,17 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
<div data-testid="third-party-reachable">
<h4 className="text-sm font-medium text-foreground">{on ? 'Still reachable' : 'Stays reachable with the switch on'}</h4>
<p className="mt-1 text-xs text-muted-foreground">Hosts you configured yourself.</p>
<p className="mt-1 text-xs text-muted-foreground">Hosts you configured yourself, and what users enter themselves.</p>
{state.reachable.length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">None configured.</p>
) : (
<ul className="mt-2 divide-y divide-border rounded-md border border-border">
{state.reachable.map((h) => (
<li key={`${h.kind}|${h.host}`} className="flex flex-col gap-0.5 px-3 py-2 text-sm sm:flex-row sm:items-baseline sm:gap-3">
<li key={`${h.kind}|${h.host}|${h.setIn}`} className="flex flex-col gap-0.5 px-3 py-2 text-sm sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-3">
<span className="font-medium text-foreground sm:w-44 sm:shrink-0">{h.kind}</span>
<span className="min-w-0 break-all font-mono text-xs text-foreground">{h.host}</span>
<span className={h.origin ? 'min-w-0 break-all font-mono text-xs text-foreground' : 'min-w-0 text-xs italic text-foreground'}>{h.host}</span>
<span className="text-xs text-muted-foreground sm:ms-auto sm:text-end">{h.setIn} · {SIDE_LABEL[h.side]}</span>
{h.note && <span className="text-xs text-amber-700 dark:text-amber-400 sm:basis-full">{h.note}</span>}
</li>
))}
</ul>
@@ -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(
<div>
{PEOPLE.map((email) => <Avatar key={email} name={email} email={email} />)}
<Avatar name="Alice" email="alice@example.com" />
</div>,
);
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(<Avatar name="Zoe" email="zoe@corp.example.net" />);
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');
});
});
+7 -3
View File
@@ -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);
+67 -1
View File
@@ -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<string, { uri?: string }> }>;
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;
}
});
});
@@ -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<string, unknown> = {};
let policy: Record<string, unknown> = {};
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<Record<string, string>> {
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:');
});
});
+57 -13
View File
@@ -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<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'>;
policy: Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'> & { features?: Partial<SettingsPolicy['features']> };
env: Record<string, string | undefined>;
/** 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<string>();
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,
};
}
+24 -4
View File
@@ -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<boolean>("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<string>();
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.