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/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/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx index 2736dc26b..9280d8b48 100644 --- a/components/admin/org/sections/third-party-section.tsx +++ b/components/admin/org/sections/third-party-section.tsx @@ -1,14 +1,190 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { useCallback, useEffect, useState } from 'react'; +import { Loader2 } from '@/components/icons'; +import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { ConfigNote, RevertButton, SourceBadge } from '@/components/admin/config-items'; +import { AdminLink } from '@/components/admin/admin-link'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { OutgoingHost } from '@/lib/admin/outgoing-hosts-registry'; +import type { ReachableHost } from '@/lib/admin/reachable-hosts'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; -/** Organisation feature 5; filled in by its own step (local-data/org-features-plan.md). */ +interface OutgoingHostsState { + noThirdPartyTraffic: boolean; + source: 'env' | 'admin' | 'default'; + readOnly: boolean; + envLocked: boolean; + builtIn: OutgoingHost[]; + reachable: ReachableHost[]; + networkPlugins: { id: string; name: string }[]; +} + +const SIDE_LABEL: Record = { + server: 'this server', + browser: 'the browser', + both: 'server and browser', +}; + +/** + * Organisation > Security > Third-party traffic (#903). The switch is config, + * not policy: it is saved through /api/admin/config like the Server page's + * keys, applies to the whole server whatever scope the scope bar shows, and + * is read-only when the environment or a read-only config sets it. + */ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) { - void draft; + const [state, setState] = useState(null); + const [failed, setFailed] = useState(false); + const [saving, setSaving] = useState(false); + const { setMessage } = draft; + + const load = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/outgoing-hosts'); + if (!res.ok) throw new Error(); + setState(await res.json()); + setFailed(false); + } catch { + setFailed(true); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + async function save(on: boolean) { + setSaving(true); + try { + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noThirdPartyTraffic: on }), + }); + if (res.ok) { + setMessage({ + type: 'success', + text: on + ? 'Third-party traffic is off. The server stops at once; browsers follow the next time Bulwark loads.' + : 'Third-party traffic is allowed again.', + }); + } else { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not save the setting' }); + } + } catch { + setMessage({ type: 'error', text: 'Could not save the setting' }); + } finally { + await load(); + setSaving(false); + } + } + + async function revert() { + setSaving(true); + try { + const res = await apiFetch('/api/admin/config', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'noThirdPartyTraffic' }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not revert the setting' }); + } + } catch { + setMessage({ type: 'error', text: 'Could not revert the setting' }); + } finally { + await load(); + setSaving(false); + } + } + + const on = !!state?.noThirdPartyTraffic; + const locked = !!state && (state.readOnly || state.envLocked); + const relays = state?.reachable.filter((h) => h.kind === 'Push relay') ?? []; + return ( -

Nothing to set here yet.

+

+ This switch is for the whole server, not per domain or group: most of what it stops, such as telemetry and the version check, runs for no particular user. +

+ + {!state && !failed && ( +
+ Loading +
+ )} + {failed &&

Could not load the outgoing hosts.

} + + {state && ( + <> + +
+ + void save(checked)} /> + {state.source === 'admin' && !locked && void revert()} />} +
+
+ + {state.envLocked && ( + Set by NO_THIRD_PARTY_TRAFFIC in the environment. Change it there and restart Bulwark. + )} + {state.readOnly && !state.envLocked && ( + The configuration is read-only. Set noThirdPartyTraffic in config.json, or NO_THIRD_PARTY_TRAFFIC in the environment. + )} + {on && relays.length === 0 && ( + + Push notifications now need a relay you run yourself. Add one under{' '} + User policy > Push relays; + until then users get new mail only while Bulwark is open. + + )} + {on && state.networkPlugins.length > 0 && ( + + Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}. + + )} + +
+

{on ? 'Still reachable' : 'Stays reachable with the switch on'}

+

Hosts you configured yourself, and what users enter themselves.

+ {state.reachable.length === 0 ? ( +

None configured.

+ ) : ( +
    + {state.reachable.map((h) => ( +
  • + {h.kind} + {h.host} + {h.setIn} · {SIDE_LABEL[h.side]} + {h.note && {h.note}} +
  • + ))} +
+ )} +
+ +
+ + Built-in outside hosts ({state.builtIn.length}){on ? ', all off' : ''} + +
    + {state.builtIn.map((h) => ( +
  • +
    + {h.host} + {h.side === 'server' ? 'this server' : 'the browser'} +
    +

    {h.usedBy}

    +

    {on ? h.inMode : `Without the switch: ${h.off}`}

    +
  • + ))} +
+
+ + )}
); } diff --git a/components/calendar/__tests__/event-detail-popover-maps.test.tsx b/components/calendar/__tests__/event-detail-popover-maps.test.tsx new file mode 100644 index 000000000..8ef172aa0 --- /dev/null +++ b/components/calendar/__tests__/event-detail-popover-maps.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// #903: an address opens Google Maps only while third-party traffic is allowed. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); + +const calendar = { id: 'cal-1', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar; + +function renderPopover() { + const event = { + id: 'evt-1', calendarIds: { 'cal-1': true }, title: 'Visit', start: '2026-11-20T10:00:00', duration: 'PT1H', + timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false, + locations: { l1: { name: 'Hauptstrasse 1, Berlin' } }, + } as unknown as CalendarEvent; + render( + , + ); + return screen.getByTitle('Hauptstrasse 1, Berlin'); +} + +afterEach(() => setNoThirdPartyTraffic(false)); + +describe('event address link', () => { + it('opens Google Maps with third-party traffic allowed', () => { + expect(renderPopover().getAttribute('href')).toMatch(/^https:\/\/www\.google\.com\/maps\//); + }); + + it('is plain text with no third-party traffic', () => { + setNoThirdPartyTraffic(true); + expect(renderPopover().getAttribute('href')).toBeNull(); + }); + + it('is plain text while the config is still unknown', () => { + setNoThirdPartyTraffic('unknown'); + expect(renderPopover().getAttribute('href')).toBeNull(); + }); +}); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 4ab0eba77..5f3f51781 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -25,6 +25,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { eventToView } from "@/lib/plugin-projection"; @@ -148,6 +149,8 @@ export function EventDetailPopover({ const isMultiDay = !isSameDay(startDate, displayEndDate); const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]); + // Re-computes the location link once the config is known (#903). + const noThirdParty = useNoThirdPartyTraffic(); // Invitations (Teams above all) leave virtualLocations empty and bury the // join URL in the description; surface it as the meeting link anyway. @@ -160,8 +163,10 @@ export function EventDetailPopover({ const locationHref = useMemo(() => { if (!locationName) return null; const action = locationAction(locationName, meeting); - return action.kind === "url" ? action.uri : mapsUrl(action.query); - }, [locationName, meeting]); + if (action.kind === "url") return action.uri; + // No Google Maps link while third-party traffic is off (#903). + return noThirdParty ? null : mapsUrl(action.query); + }, [locationName, meeting, noThirdParty]); const [locationCopied, setLocationCopied] = useState(false); const copiedTimer = useRef | null>(null); useEffect(() => () => { @@ -493,7 +498,7 @@ export function EventDetailPopover({ href={locationHref ?? undefined} target="_blank" rel="noreferrer" - className="text-sm text-primary hover:underline min-w-0 break-words" + className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")} title={locationName} > {/^https?:\/\//i.test(locationName) diff --git a/components/email/__tests__/remote-content-no-third-party.test.tsx b/components/email/__tests__/remote-content-no-third-party.test.tsx new file mode 100644 index 000000000..cfffb131b --- /dev/null +++ b/components/email/__tests__/remote-content-no-third-party.test.tsx @@ -0,0 +1,90 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { useSettingsStore } from '@/stores/settings-store'; + +// #903: with no third-party traffic, remote content in mail never loads, so +// nothing offers to load it or to trust the sender; the notice says the +// organisation blocks it. Without the mode the controls are as before. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); +const { ThreadConversationView } = await import('../thread-conversation-view'); + +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; +const email = { + id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }], + subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: '

Hello

' } }, +} as unknown as Email; + +afterEach(() => { + setNoThirdPartyTraffic(false); + useSettingsStore.setState({ externalContentPolicy: 'ask' }); +}); + +async function renderViewer() { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + return screen.findByTestId('external-content-notice'); +} + +async function renderConversation() { + const view = render( + {}} />, + ); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + return view; +} + +describe('reading pane', () => { + it('offers to load remote content without the mode', async () => { + const notice = await renderViewer(); + expect(notice.textContent).toContain('external_content_warning'); + expect(screen.queryByText('load_external_content')).not.toBeNull(); + expect(screen.queryByText('trust_sender')).not.toBeNull(); + }); + + it('says the organisation blocks it, with no load or trust button, even when the policy allows', async () => { + setNoThirdPartyTraffic(true); + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + const notice = await renderViewer(); + expect(notice.textContent).toContain('remote_content_blocked'); + expect(screen.queryByText('load_external_content')).toBeNull(); + expect(screen.queryByText('trust_sender')).toBeNull(); + }); +}); + +describe('conversation view', () => { + it('offers to load remote content without the mode', async () => { + await renderConversation(); + expect(screen.getByTestId('external-content-notice').textContent).toContain('email_viewer.external_content_warning'); + expect(screen.queryByText('email_viewer.load_external_content')).not.toBeNull(); + }); + + it('says the organisation blocks it, keeps it blocked and offers nothing in the mode', async () => { + setNoThirdPartyTraffic(true); + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + const { container } = await renderConversation(); + expect(screen.getByTestId('external-content-notice').textContent).toContain('third_party.remote_content_blocked'); + expect(screen.queryByText('email_viewer.load_external_content')).toBeNull(); + expect(screen.queryByText('email_viewer.trust_sender')).toBeNull(); + const srcdoc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? ''; + expect(srcdoc).not.toMatch(/\ssrc="https:\/\/track\.shop\.example/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d0f6099ab..52e2307bc 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -151,6 +151,7 @@ import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; /** The More menu's two drill-downs: a folder list and a tag list. */ type MoreMenuSub = 'move' | 'tag' | 'rules'; @@ -1094,6 +1095,10 @@ export function EmailViewer({ const tWelcome = useTranslations('welcome'); const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); + // No third-party traffic (#903): remote content never loads, whatever the + // policy or trust says, and nothing offers to load it. + const remoteBlockedByOrg = useNoThirdPartyTraffic(); + const tThirdParty = useTranslations('third_party'); const messageSpacing = useSettingsStore((state) => state.messageSpacing); const plainTextFont = useSettingsStore((state) => state.plainTextFont); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); @@ -2498,6 +2503,7 @@ export function EmailViewer({ // 'ask' = block until the user allows this message or trusts the sender. const shouldBlockExternal = useMemo(() => { if (!email) return false; + if (remoteBlockedByOrg) return true; const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) @@ -2510,7 +2516,7 @@ export function EmailViewer({ // permission changes, so they're deliberately omitted from deps (matches // the srcDoc rebuild contract described on `emailContent`). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email, externalContentPolicy, allowExternalContent]); + }, [email, externalContentPolicy, allowExternalContent, remoteBlockedByOrg]); // Sanitize and prepare email HTML content const emailContent = useMemo(() => { @@ -5873,7 +5879,7 @@ export function EmailViewer({ {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {(senderVerification || - (hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || + (hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) || hasCalendarInvitation || (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
@@ -5903,8 +5909,8 @@ export function EmailViewer({ )} {/* External Content Controls */} - {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( -
+ {hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && ( +
@@ -5914,9 +5920,10 @@ export function EmailViewer({ External Content
- {t('external_content_warning')} + {remoteBlockedByOrg ? tThirdParty('remote_content_blocked') : t('external_content_warning')}
+ {!remoteBlockedByOrg && (
{externalContentPolicy === 'ask' && (
+ )}
)} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 7c5dbc5df..7e60b7b56 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -57,6 +57,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query"; import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body"; import { emailToReadView } from "@/lib/plugin-projection"; import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; export type ConversationAttachmentHandler = ( blobId: string, name: string, type: string | undefined, source: EmailBlobContext, @@ -114,6 +115,8 @@ export function useConversationExternalContent() { const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); const [allowedIds, setAllowedIds] = useState>(new Set()); + // No third-party traffic (#903): nothing remote loads and nothing offers to. + const remoteBlockedByOrg = useNoThirdPartyTraffic(); const allow = useCallback((emailId: string) => { setAllowedIds(prev => new Set(prev).add(emailId)); @@ -124,12 +127,13 @@ export function useConversationExternalContent() { const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) : false; + if (remoteBlockedByOrg) return false; return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id); }; const trustSender = (email: Email) => { const senderEmail = email.from?.[0]?.email?.toLowerCase(); - if (!senderEmail) return undefined; + if (!senderEmail || remoteBlockedByOrg) return undefined; return () => { if (trustedSendersAddressBook && client) { addToTrustedSendersBook(client, senderEmail).catch(console.error); @@ -525,6 +529,7 @@ export function ConversationEmailBody({ bodyStyle, className, }: ConversationEmailBodyProps) { + const remoteBlockedByOrg = useNoThirdPartyTraffic(); const t = useTranslations(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); @@ -834,10 +839,11 @@ export function ConversationEmailBody({
{/* External content warning */} {hasBlockedContent && !allowExternal && ( -
+
- {t("email_viewer.external_content_warning")} + {remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")} + {!remoteBlockedByOrg && (
+ )}
)} diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index b9a943611..27e73ddcf 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -6,6 +6,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza import { getEffectiveTimeZone } from "@/lib/timezone"; import { stripBidiControls } from "@/lib/bidi"; import { useSettingsStore } from "@/stores/settings-store"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; // A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields // this preview renders are typed. @@ -43,7 +44,9 @@ function escapeHtml(s: string): string { // (FilePreviewModal); this is pure presentation. export function EmlPreview({ message }: { message: ParsedEml }) { const t = useTranslations("email_viewer"); - const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow"; + // Always blocked with no third-party traffic (#903). + const remoteBlockedByOrg = useNoThirdPartyTraffic(); + const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; // dir="auto" on a wrapper we control lets the first strong character pick // the direction, matching the main viewer. diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 4e046e5ad..cf7234554 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -14,6 +14,7 @@ import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; import { useSettingsStore } from "@/stores/settings-store"; import type { FileResourceView } from "@/lib/plugin-types"; +import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; /** * An HTML attachment as a document for a fully locked frame (sandbox="", @@ -161,7 +162,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // open-in-new-tab fallback use. const [pdfBlob, setPdfBlob] = useState(null); const [htmlDoc, setHtmlDoc] = useState(null); - const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow"; + // Always blocked with no third-party traffic (#903). + const remoteBlockedByOrg = useNoThirdPartyTraffic(); + const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; // Decide whether to render the PDF in a plain