From 1a747ac9dd67fca1b0538f662517333c54ce9ed4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:30:41 +0100 Subject: [PATCH] feat: limit the page's CSP to the configured hosts with no third-party traffic #903 --- app/api/dev-jmap/[...path]/route.ts | 9 ++- .../__tests__/third-party-section.test.tsx | 16 +++++ .../org/sections/third-party-section.tsx | 9 +-- .../__tests__/avatar-sample-photos.test.tsx | 61 ++++++++++++++++ components/ui/avatar.tsx | 10 ++- lib/__tests__/no-third-party-server.test.ts | 68 +++++++++++++++++- .../proxy-no-third-party-csp.test.ts | 69 ++++++++++++++++++ lib/admin/reachable-hosts.ts | 70 +++++++++++++++---- proxy.ts | 28 ++++++-- 9 files changed, 314 insertions(+), 26 deletions(-) create mode 100644 components/ui/__tests__/avatar-sample-photos.test.tsx create mode 100644 lib/__tests__/proxy-no-third-party-csp.test.ts diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 7b8128fdb..d437585ce 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -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]>; diff --git a/components/admin/__tests__/third-party-section.test.tsx b/components/admin/__tests__/third-party-section.test.tsx index 1fe1813eb..8ecadf4ac 100644 --- a/components/admin/__tests__/third-party-section.test.tsx +++ b/components/admin/__tests__/third-party-section.test.tsx @@ -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)[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], ]); }); }); diff --git a/components/admin/org/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx index 0873524f6..9280d8b48 100644 --- a/components/admin/org/sections/third-party-section.tsx +++ b/components/admin/org/sections/third-party-section.tsx @@ -119,7 +119,7 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) { <>
@@ -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.