diff --git a/.github/workflows/static-lite.yml b/.github/workflows/static-lite.yml index 149e12036..9def1243f 100644 --- a/.github/workflows/static-lite.yml +++ b/.github/workflows/static-lite.yml @@ -81,6 +81,12 @@ jobs: LITE_DEMO_MODE: ${{ inputs.demo }} run: npm run build:lite + # Every host literal in the bundle must be a placeholder, the configured + # JMAP server, a built-in host the no-third-party switch turns off, or a + # string listed as never requested (#903). + - name: Scan the bundle for outside hosts + run: node scripts/lite/scan-hosts.mjs out + - name: Package zip env: REF_NAME: ${{ github.ref_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7365c1069..cef331103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ - **Organisation**: Admins can set an organisation signature with placeholders for each user's name, address, company, job title, department and phone numbers, per domain or group, and lock it so it replaces everyone's own signature. Users fill in their details once under Settings > Account. Lines whose details are empty are left out (#908) - **Organisation**: Admins can add a legal disclaimer that goes below every message when it is sent, never into drafts, and is shown read-only in the composer +- **Organisation**: Admins can label mail from outside the organisation with an "External" chip and have users confirm before sending to or inviting external recipients (composer, quick reply and meeting invitations), per domain or group. Partner domains and contacts count as internal. This replaces the External Mail Warning plugin, whose safe domains can be imported. Plugins can read `api.host.features.externalSenderLabel` and `externalRecipientWarning` - **Organisation**: Admins can sign users out after a time without activity or a fixed time after sign-in, per domain or group. A countdown with "Stay signed in" comes first, open and minimised messages and quick replies are saved as drafts, and the login page says why +- **Delegates**: Settings > Sharing gives a colleague read, or read and write, access to all mail folders or just the Inbox, calendars and contacts in one step, lists who has access to what, and removes access everywhere at once. Admins can hide it per domain or group and limit every share dialog to read access on Organisation > Sharing +- **Privacy**: One switch, `NO_THIRD_PARTY_TRAFFIC` or Organisation > Security, stops every connection to services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, sender icons, MyMemory translation, map links, OpenPGP key lookups and remote pictures in mail. Plugins that need network access don't run, and the page's CSP allows only the hosts you configured. Push registrations move to your own relay. The admin page lists what stays reachable (#903) ## 1.18.0 (2026-10-26) diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 6d63d4ee0..95bf81641 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; +import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; @@ -67,6 +68,7 @@ export default async function LocaleLayout({ {children} + diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx index a745ac45c..796a1564d 100644 --- a/app/(main)/admin/_pages/telemetry.tsx +++ b/app/(main)/admin/_pages/telemetry.tsx @@ -15,7 +15,7 @@ import { cn } from '@/lib/utils'; interface TelemetryStatus { consent: 'pending' | 'on' | 'off'; - consentSource: 'env' | 'file'; + consentSource: 'no-third-party' | 'env' | 'file'; endpoint: string; defaultEndpoint: string; consentedAt: string | null; @@ -132,14 +132,16 @@ export function TelemetryPage() { ); } - const envOverridden = status.consentSource === 'env'; + const noThirdParty = status.consentSource === 'no-third-party'; + const envOverridden = status.consentSource === 'env' || noThirdParty; const isOn = status.consent === 'on'; const consentDescription = [ status.consent === 'pending' && 'Initialising - no heartbeats sent yet.', status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!', status.consent === 'off' && 'Heartbeats are off (default).', - envOverridden && 'Locked by the BULWARK_TELEMETRY env var.', + noThirdParty && 'Locked off: no third-party traffic is on (Organisation > Security).', + envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.', ].filter(Boolean).join(' '); return ( diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx index e9dcd6d0c..7a700786a 100644 --- a/app/(main)/admin/_pages/version.tsx +++ b/app/(main)/admin/_pages/version.tsx @@ -24,6 +24,7 @@ interface VersionAdminStatus { endpoint: string; defaultEndpoint: string; disabledByEnv: boolean; + disabledByNoThirdPartyTraffic?: boolean; lastCheckedAt: string | null; lastSuccessAt: string | null; nextScheduledAt: string | null; @@ -206,6 +207,11 @@ export function VersionPage() { {data.disabledByEnv ? 'Yes' : 'No'} + {data.disabledByNoThirdPartyTraffic && ( + + Yes + + )} diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 2981b5b8a..33e3cd203 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -10,6 +10,7 @@ import { logger } from '@/lib/logger'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url'; +import { parseNoThirdPartyEnv } from '@/lib/third-party-mode'; // Strings that count as "no real secret configured" - used so the dashboard // can warn about a placeholder session secret without us ever returning the @@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) { if (problem) return NextResponse.json({ error: problem }, { status: 400 }); } + // The no-third-party switch is fail-closed: an "on" from the environment + // can't be undone from the panel (lib/admin/third-party-mode.ts). + if ('noThirdPartyTraffic' in updates) { + if (typeof updates.noThirdPartyTraffic !== 'boolean') { + return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 }); + } + if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) { + return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 }); + } + } + // Enum keys only accept their declared values; a stray string would // otherwise be persisted and fail closed (or open) at the read site. for (const [key, value] of Object.entries(updates)) { diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index 4df6da909..f35fd899b 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -12,6 +12,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, historyFor, installedExtensions, installedStateOf, @@ -65,6 +67,9 @@ export async function GET( try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const { slug } = await params; const requested = request.nextUrl.searchParams.get('version'); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 50404e882..19cfb739e 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -32,6 +32,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, installedStateOf, readBundleBytes, @@ -57,6 +59,9 @@ export async function GET(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const directoryUrl = await getDirectoryUrl(); const query: Record = {}; @@ -105,6 +110,9 @@ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const ip = getClientIP(request); const { slug, version, type, force } = await request.json(); diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts index 52b14e8cd..7d2643452 100644 --- a/app/api/admin/marketplace/updates/route.ts +++ b/app/api/admin/marketplace/updates/route.ts @@ -8,6 +8,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, v2Url, type DirectoryAdvisory, @@ -43,6 +45,13 @@ export async function GET(request: NextRequest) { const list = [...installed].map(([id, e]) => ({ id, ...e })); const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version })); + // The installed list is still useful with the official directory off (#903). + if (await directoryOff()) { + return NextResponse.json( + { installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } const directoryUrl = await getDirectoryUrl(); const channel = getDirectoryChannel(); let answer: UpdatesAnswer = EMPTY; diff --git a/app/api/admin/outgoing-hosts/route.ts b/app/api/admin/outgoing-hosts/route.ts new file mode 100644 index 000000000..d7eaeccce --- /dev/null +++ b/app/api/admin/outgoing-hosts/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/admin/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { isConfigReadOnly } from '@/lib/admin/paths'; +import { getPluginRegistry } from '@/lib/admin/plugin-registry'; +import { noThirdPartyTrafficState } from '@/lib/admin/third-party-mode'; +import { listReachableHosts } from '@/lib/admin/reachable-hosts'; +import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/admin/outgoing-hosts - the "no third-party traffic" switch with + * where its value comes from, the built-in outside hosts it turns off, the + * admin-configured hosts that stay reachable, and the installed plugins it + * refuses (#903). Organisation > Security renders this; the switch itself is + * saved through PATCH /api/admin/config like every other config key. + */ +export async function GET(request: NextRequest) { + try { + const auth = await requireAdminAuth(request); + if ('error' in auth) return auth.error; + + await configManager.ensureLoaded(); + const state = noThirdPartyTrafficState(); + const policy = configManager.getPolicy(); + const registry = await getPluginRegistry().catch(() => ({ plugins: [] })); + + return NextResponse.json( + { + noThirdPartyTraffic: state.on, + source: state.source, + // The panel can't turn it off when the environment pins it on, and + // can't change it at all on a read-only config. + readOnly: isConfigReadOnly(), + envLocked: state.source === 'env', + builtIn: OUTGOING_HOSTS, + reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env }), + networkPlugins: registry.plugins + .filter((plugin) => plugin.enabled && pluginDeclaresNetwork(plugin)) + .map((plugin) => ({ id: plugin.id, name: plugin.name })), + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + logger.error('Outgoing hosts read error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts index 192e64b31..daa5c19f8 100644 --- a/app/api/admin/telemetry/route.ts +++ b/app/api/admin/telemetry/route.ts @@ -81,6 +81,12 @@ export async function POST(request: NextRequest) { { status: 409 }, ); } + if (source === 'no-third-party') { + return NextResponse.json( + { error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' }, + { status: 409 }, + ); + } if (body.consent !== 'on' && body.consent !== 'off') { return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 }); } diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts index 2c63a7b33..004db83d2 100644 --- a/app/api/admin/version/route.ts +++ b/app/api/admin/version/route.ts @@ -9,6 +9,7 @@ import { disabledByEnv, DEFAULT_VERSION_ENDPOINT, } from '@/lib/version-check'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * GET /api/admin/version @@ -27,6 +28,7 @@ export async function GET(request: NextRequest) { endpoint: effectiveEndpoint(state), defaultEndpoint: DEFAULT_VERSION_ENDPOINT, disabledByEnv: disabledByEnv(), + disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(), lastCheckedAt: state.lastCheckedAt, lastSuccessAt: state.lastSuccessAt, nextScheduledAt: state.nextScheduledAt, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 1faf393c3..d2293318d 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -11,6 +11,7 @@ import { pickRequestHost, type BrandingOverrideKey, } from '@/lib/admin/domain-branding'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * Runtime configuration endpoint @@ -98,6 +99,8 @@ export async function GET(request: NextRequest) { embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')), + // Browser-side switches of lib/outgoing-hosts.ts read this (#903). + noThirdPartyTraffic: await isNoThirdPartyTrafficOn(), }, { // Runtime settings can change without new asset URLs. Prevent stale diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts index 4ff6fd1a1..2235298b9 100644 --- a/app/api/connector/capabilities/route.ts +++ b/app/api/connector/capabilities/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; import { TARGET_NAMES } from '@/lib/connector/registry'; /** @@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry'; export async function GET() { await configManager.ensureLoaded(); - if (!configManager.get('connectorEnabled', true)) { + if (!configManager.get('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) { return new NextResponse(null, { status: 404 }); } 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/app/api/favicon/route.ts b/app/api/favicon/route.ts index 7dc48fab8..311757fbe 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // In-memory LRU cache: domain -> { data, contentType, fetchedAt } const CACHE_MAX_SIZE = 1000; @@ -439,6 +440,10 @@ function evictOldest() { } export async function GET(request: NextRequest) { + // Sender icons come from DuckDuckGo: off, before anything else, while the + // server must not contact outside services (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons'); + const domain = request.nextUrl.searchParams.get('domain'); if (!domain || !isValidDomain(domain)) { diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index 5da089c90..2715160e7 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials'; @@ -41,6 +42,8 @@ export async function POST(request: NextRequest) { if (!configManager.getPolicy().features.pluginsEnabled) { return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 }); } + // Every proxied request goes to a host outside this deployment (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy'); const creds = await getStalwartCredentials(request); if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 }); diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts index 60b85a064..7f33684cb 100644 --- a/app/api/plugin-secrets/__tests__/route.test.ts +++ b/app/api/plugin-secrets/__tests__/route.test.ts @@ -13,7 +13,7 @@ vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => (signedIn ? { ...claimed, serverUrl: 'https://mail.example.org', authHeader: 'x' } : null), })); vi.mock('@/lib/admin/config-manager', () => ({ - configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) }, + configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled } }) }, })); vi.mock('@/lib/security/same-origin', async () => { const { NextResponse } = await import('next/server'); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 238ecc0e7..b5652688a 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; +import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -32,11 +34,14 @@ export async function GET(request: NextRequest) { // plugin without uninstalling it first. const devIds = new Set(devEntries.map(e => e.plugin.id)); const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id)); + // Plugins that declare network access never reach a browser while the + // server must stay off outside hosts (#903); the rest run as usual. + const noThirdParty = await isNoThirdPartyTrafficOn(); const plugins = [ ...devEntries.map(e => ({ ...e.plugin, dev: true })), ...installedEnabled.map(p => ({ ...p, dev: false })), - ].map(p => ({ + ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({ id: p.id, name: p.name, version: p.version, diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index cdf729636..7507303f6 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { readFileEnv } from "@/lib/read-file-env"; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // Host-side proxy backing the "Translate" plugin (manifest apiPostPaths: // ["/api/translate"]). The plugin slot iframe POSTs { text, target, source, @@ -284,6 +285,10 @@ export async function POST(request: NextRequest) { const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en'; const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto'; const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory'; + // MyMemory is a public service; the admin's own LibreTranslate is not (#903). + if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) { + return thirdPartyRefusal('Translation through MyMemory'); + } if (!text) { return NextResponse.json({ error: 'No text to translate' }, { status: 400 }); diff --git a/components/admin/__tests__/third-party-section.test.tsx b/components/admin/__tests__/third-party-section.test.tsx new file mode 100644 index 000000000..8ecadf4ac --- /dev/null +++ b/components/admin/__tests__/third-party-section.test.tsx @@ -0,0 +1,129 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { listReachableHosts } from '@/lib/admin/reachable-hosts'; +import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry'; +import type { OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; + +const apiFetch = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', async (orig) => ({ + ...(await orig()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +const { ThirdPartySection } = await import('@/components/admin/org/sections/third-party-section'); + +const setMessage = vi.fn(); +const draft = { setMessage } as unknown as OrgPolicyDraft; + +function state(overrides: Record = {}) { + return { + noThirdPartyTraffic: false, + source: 'default', + readOnly: false, + envLocked: false, + builtIn: OUTGOING_HOSTS, + reachable: [{ kind: 'JMAP server', host: 'mail.corp.example', setIn: 'JMAP_SERVER_URL', side: 'both' }], + networkPlugins: [], + ...overrides, + }; +} + +function switchOf() { + return within(screen.getByTestId('third-party-switch')).getByRole('switch'); +} + +beforeEach(() => { + apiFetch.mockReset(); + setMessage.mockReset(); +}); + +describe('ThirdPartySection', () => { + it('saves the switch as config through /api/admin/config', async () => { + let current = state(); + apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === '/api/admin/config' && init?.method === 'PATCH') { + current = state({ noThirdPartyTraffic: true, source: 'admin' }); + return Response.json({ ok: true }); + } + return Response.json(current); + }); + render(); + await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'false')); + expect(screen.getByTestId('third-party-scope-note').textContent).toMatch(/whole server, not per domain or group/); + expect(screen.getByTestId('third-party-reachable').textContent).toContain('mail.corp.example'); + + await act(async () => { fireEvent.click(switchOf()); }); + expect(apiFetch).toHaveBeenCalledWith('/api/admin/config', expect.objectContaining({ + method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: true }), + })); + await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'true')); + expect(screen.getByText('config.json')).toBeInTheDocument(); + // No relay of their own: push needs one now. + expect(screen.getByText(/Push notifications now need a relay you run yourself/)).toBeInTheDocument(); + }); + + it('is read-only when the environment sets it', async () => { + apiFetch.mockImplementation(async () => Response.json(state({ noThirdPartyTraffic: true, source: 'env', envLocked: true }))); + render(); + await waitFor(() => expect(switchOf()).toBeDisabled()); + expect(screen.getByText('NO_THIRD_PARTY_TRAFFIC')).toBeInTheDocument(); + expect(screen.getByText(/Set by NO_THIRD_PARTY_TRAFFIC in the environment/)).toBeInTheDocument(); + }); + + it('is read-only on a read-only config', async () => { + apiFetch.mockImplementation(async () => Response.json(state({ readOnly: true }))); + render(); + await waitFor(() => expect(switchOf()).toBeDisabled()); + expect(screen.getByText(/The configuration is read-only/)).toBeInTheDocument(); + }); +}); + +describe('listReachableHosts', () => { + it('lists the hosts the deployment configured and leaves the built-in ones out', () => { + const config: Record = { + jmapServerUrl: 'https://mail.corp.example', + jmapServers: [{ id: 'b', label: 'Branch', url: 'https://jmap.branch.example', oauth: { issuerUrl: 'https://sso.corp.example' } }], + oauthEnabled: false, + oauthIssuerUrl: 'https://ignored.example', + extensionDirectoryUrl: 'https://extensions.bulwarkmail.org', + wopiClientUrl: 'https://office.corp.example', + loginLogoLightUrl: '/branding/Bulwark_Logo_Color.svg', + appLogoLightUrl: 'https://cdn.corp.example/logo.svg', + }; + const hosts = listReachableHosts({ + get: (key) => config[key], + policy: { + pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }, { label: 'Built-in', url: 'https://notifications.relay.bulwarkmail.org' }], + pushRelayUrl: '', + defaultSidebarApps: [{ id: 'w', name: 'Wiki', url: 'https://wiki.corp.example', icon: 'Globe', openMode: 'inline', showOnMobile: true }], + }, + env: { LIBRETRANSLATE_URL: 'https://translate.corp.example' }, + }); + expect(hosts.map((h) => `${h.kind}: ${h.host}`)).toEqual([ + 'JMAP server: mail.corp.example', + 'JMAP server: jmap.branch.example', + 'OAuth issuer: sso.corp.example', + 'Push relay: push.corp.example', + 'Sidebar app: wiki.corp.example', + '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/__tests__/external-mail-section.test.tsx b/components/admin/org/__tests__/external-mail-section.test.tsx new file mode 100644 index 000000000..29a1ff1b3 --- /dev/null +++ b/components/admin/org/__tests__/external-mail-section.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Security > External mail: the switches and the domain list +// go into the draft at the chosen scope, and the External Mail Warning +// plugin's safe domains can be copied in as an unsaved change. + +const puts: Array> = []; +let stored: Record = {}; +let plugins: Array> = []; +let pluginConfig: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + if (url === '/api/admin/plugins') return new Response(JSON.stringify(plugins)); + if (url === '/api/admin/plugins/external-mail-warning/config') return new Response(JSON.stringify(pluginConfig)); + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { ExternalMailSection } from '../sections/external-mail-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { externalMail: { domains: ['partner.com'] } }, + scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-external-sender-label')).toBeInTheDocument()); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + plugins = []; + pluginConfig = {}; + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('External mail section', () => { + it('starts with both switches off and saves what the admin turns on', async () => { + await renderSection(); + expect(screen.getByTestId('org-external-sender-label').getAttribute('aria-checked')).toBe('false'); + expect(screen.getByTestId('org-external-recipient-warning').getAttribute('aria-checked')).toBe('false'); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + fireEvent.click(screen.getByTestId('org-external-recipient-warning')); + await act(async () => { await latest!.save(); }); + expect((puts[0].organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, domains: ['partner.com'] }); + }); + + it('writes a scope override only into that scope', async () => { + await renderSection(); + act(() => latest!.setScopeId('two')); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ externalMail: { senderLabel: true } }); + expect(latest!.policy?.organisation?.externalMail.senderLabel).toBe(false); + }); + + it('takes the typed domain list apart when the field is left', async () => { + await renderSection(); + const field = screen.getByTestId('org-external-domains'); + fireEvent.change(field, { target: { value: 'partner.com, Sister.example\n@third.test' } }); + fireEvent.blur(field); + expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'sister.example', 'third.test']); + expect(screen.queryByTestId('org-external-domains-rejected')).toBeNull(); + }); + + it('stores an IDN in ASCII, shows it in Unicode and names entries that are not domains', async () => { + await renderSection(); + const field = screen.getByTestId('org-external-domains') as HTMLTextAreaElement; + fireEvent.change(field, { target: { value: 'bücher.de\nnot_a_domain, x' } }); + fireEvent.blur(field); + expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['xn--bcher-kva.de']); + expect(screen.getByTestId('org-external-domains-rejected').textContent).toContain('not_a_domain, x'); + await waitFor(() => expect(field.value).toBe('bücher.de'), { timeout: 5000 }); + }); + + it('offers no import and no note without the plugin', async () => { + await renderSection(); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(screen.queryByTestId('org-external-import-plugin')).toBeNull(); + expect(screen.queryByTestId('org-external-plugin-note')).toBeNull(); + }); + + it('asks to update an old plugin while the warnings are on and imports its safe domains as a draft change', async () => { + plugins = [{ id: 'external-mail-warning', version: '1.5.1' }]; + pluginConfig = { safeDomains: 'partner.com, acme.example\nsub.acme.example' }; + await renderSection(); + await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument()); + expect(screen.queryByTestId('org-external-plugin-note')).toBeNull(); + + fireEvent.click(screen.getByTestId('org-external-recipient-warning')); + expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('Update or remove the External Mail Warning plugin'); + + await act(async () => { fireEvent.click(screen.getByTestId('org-external-import-plugin')); }); + await waitFor(() => expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'acme.example', 'sub.acme.example'])); + expect(latest!.dirty).toBe(true); + expect(puts).toHaveLength(0); + }); + + it('only suggests removing a plugin that steps back by itself', async () => { + plugins = [{ id: 'external-mail-warning', version: '1.6.0' }]; + await renderSection(); + await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('You can remove the External Mail Warning plugin'); + }); +}); diff --git a/components/admin/org/sections/delegates-section.tsx b/components/admin/org/sections/delegates-section.tsx index d6af70217..1fecb99c5 100644 --- a/components/admin/org/sections/delegates-section.tsx +++ b/components/admin/org/sections/delegates-section.tsx @@ -1,14 +1,34 @@ 'use client'; import { SettingsSection } from '@/components/settings/settings-section'; +import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys'; +import { RestrictionRow } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; -/** Organisation feature 7; filled in by its own step (local-data/org-features-plan.md). */ +const DELEGATION = RESTRICTION_KEYS.find((def) => def.key === 'delegation')!; +const SHARING_RIGHTS = RESTRICTION_KEYS.find((def) => def.key === 'sharingRights')!; + +/** Organisation > Sharing > Delegates: whether users may delegate, and which rights any share dialog offers. */ export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; + const rights = draft.restriction(SHARING_RIGHTS.key).value; + const noneTicked = Array.isArray(rights?.allowedValues) && rights.allowedValues.length === 0; return ( - -

Nothing to set here yet.

+ + + + {noneTicked && ( +

+ With neither ticked nothing is limited, so users can give both. Tick "read" to allow read access only. +

+ )} +

+ Both limit what Bulwark offers. Stalwart doesn't enforce them, so users can still share through other clients. Shared + mailboxes of groups are yours to manage in Stalwart. +

); } diff --git a/components/admin/org/sections/external-mail-section.tsx b/components/admin/org/sections/external-mail-section.tsx index 44c7a8a5a..e77c38efc 100644 --- a/components/admin/org/sections/external-mail-section.tsx +++ b/components/admin/org/sections/external-mail-section.tsx @@ -1,14 +1,184 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { useEffect, useState } from 'react'; +import { Download, Info } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section'; +import { AdminLink } from '@/components/admin/admin-link'; +import { parseDomainList } from '@/lib/admin/organisation-policy'; +import { toUnicodeDomain } from '@/lib/idn'; +import { apiFetch } from '@/lib/browser-navigation'; +import { EXTERNAL_MAIL_WARNING_PLUGIN_ID } from '@/lib/plugin-sandbox/host-features'; +import { InheritNote } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; -/** Organisation feature 2; filled in by its own step (local-data/org-features-plan.md). */ +/** The first release of the plugin core took over from that steps back on its own. */ +export const PLUGIN_STEPS_BACK_FROM = '1.6.0'; + +function older(version: string, than: string): boolean { + const a = version.split(/[-+]/)[0].split('.').map(Number); + const b = than.split(/[-+]/)[0].split('.').map(Number); + for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0); + return false; +} + +type InstalledPlugin = { version: string }; + +/** The External Mail Warning plugin when the admin installed it, else null. */ +function useInstalledPlugin(): InstalledPlugin | null { + const [plugin, setPlugin] = useState(null); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await apiFetch('/api/admin/plugins'); + const list = res.ok ? ((await res.json()) as Array<{ id: string; version?: string }>) : []; + const found = Array.isArray(list) ? list.find((p) => p.id === EXTERNAL_MAIL_WARNING_PLUGIN_ID) : undefined; + if (!cancelled) setPlugin(found ? { version: found.version ?? '' } : null); + } catch { + if (!cancelled) setPlugin(null); + } + })(); + return () => { cancelled = true; }; + }, []); + return plugin; +} + +const onOff = (v: boolean) => (v ? 'on' : 'off'); + +/** + * Organisation > Security > External mail: the "External" sender label, + * the confirm before sending outside, which domains are internal, and the + * hand-over from the External Mail Warning plugin. + */ export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; + const senderLabel = draft.orgValue('externalMail', 'senderLabel'); + const recipientWarning = draft.orgValue('externalMail', 'recipientWarning'); + const domains = draft.orgValue('externalMail', 'domains'); + const trustContacts = draft.orgValue('externalMail', 'trustContacts'); + const plugin = useInstalledPlugin(); + const anyOn = senderLabel.value || recipientWarning.value; + + // The list is typed as text and taken apart when the field is left, so a + // half-typed domain isn't thrown away on every key. It is stored in ASCII + // (xn--) form and shown in Unicode; entries that aren't domains are named. + const stored = domains.value.join('\n'); + const joined = domains.value.map(toUnicodeDomain).join('\n'); + const [text, setText] = useState(joined); + const [rejected, setRejected] = useState([]); + useEffect(() => { setText(joined); }, [joined]); + const commitDomains = (raw: string) => { + const parsed = parseDomainList(raw) ?? { domains: [], rejected: [] }; + setRejected(parsed.rejected); + if (parsed.domains.join('\n') !== stored) draft.setOrgValue('externalMail', 'domains', parsed.domains); + else setText(joined); + }; + + const [importing, setImporting] = useState(false); + const importFromPlugin = async () => { + setImporting(true); + try { + const res = await apiFetch(`/api/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}/config`); + if (!res.ok) throw new Error(String(res.status)); + const config = (await res.json()) as Record; + const parsed = parseDomainList(config.safeDomains) ?? { domains: [], rejected: [] }; + const fromPlugin = parsed.domains; + setRejected(parsed.rejected); + const added = fromPlugin.filter((d) => !domains.value.includes(d)); + if (added.length > 0) draft.setOrgValue('externalMail', 'domains', [...domains.value, ...added]); + draft.setMessage({ + type: 'success', + text: added.length > 0 + ? `Added ${added.length} ${added.length === 1 ? 'domain' : 'domains'} from the plugin. Save to apply them.` + : fromPlugin.length > 0 ? 'The list already has every domain the plugin had.' : 'The plugin has no safe domains set.', + }); + } catch { + draft.setMessage({ type: 'error', text: 'Could not read the plugin settings' }); + } finally { + setImporting(false); + } + }; + + const outdated = !!plugin && older(plugin.version, PLUGIN_STEPS_BACK_FROM); + return ( - -

Nothing to set here yet.

+ + +
+ draft.setOrgValue('externalMail', 'senderLabel', v)} testId="org-external-sender-label" /> + draft.clearOrgValue('externalMail', 'senderLabel')} /> +
+
+ + +
+ draft.setOrgValue('externalMail', 'recipientWarning', v)} testId="org-external-recipient-warning" /> + draft.clearOrgValue('externalMail', 'recipientWarning')} /> +
+
+ + +
+ draft.setOrgValue('externalMail', 'trustContacts', v)} testId="org-external-trust-contacts" /> + draft.clearOrgValue('externalMail', 'trustContacts')} /> +
+
+ +
+ +

+ Your users' own domains always count as internal. Add partner or sister-company domains here, one per line or separated by commas. A domain covers its subdomains: example.com also covers eu.example.com. +

+