From e73a1b375f1b0e07fac8a889310a7894906a4872 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:39:35 +0100 Subject: [PATCH] feat: let link-check plugins reach an admin-set service with no third-party traffic The plugin host tests compared api.host.features with an object of two flags; they now include the new noThirdPartyTraffic flag. --- app/api/admin/outgoing-hosts/route.ts | 24 ++++++-- app/api/plugin-proxy/route.ts | 15 ++++- app/api/plugins/route.ts | 7 ++- .../org/sections/third-party-section.tsx | 7 +++ lib/__tests__/no-third-party-client.test.ts | 15 ++++- lib/__tests__/no-third-party-server.test.ts | 58 ++++++++++++++++++- lib/__tests__/plugin-host-features.test.ts | 33 ++++++++--- lib/admin/reachable-hosts.ts | 12 +++- lib/outgoing-hosts.ts | 40 ++++++++++++- lib/plugin-sandbox/host-api.ts | 8 ++- lib/plugin-sandbox/host-features.ts | 19 +++--- lib/plugin-sandbox/runtime.tsx | 5 +- lib/plugin-secrets/proxy.ts | 20 +++++++ lib/plugin-types.ts | 8 +++ 14 files changed, 238 insertions(+), 33 deletions(-) diff --git a/app/api/admin/outgoing-hosts/route.ts b/app/api/admin/outgoing-hosts/route.ts index d7eaeccce..67597c3ac 100644 --- a/app/api/admin/outgoing-hosts/route.ts +++ b/app/api/admin/outgoing-hosts/route.ts @@ -5,7 +5,9 @@ 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 { pluginAllowedInMode, pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { adminEnteredOrigins } from '@/lib/plugin-secrets/proxy'; +import type { CredentialSpec } from '@/lib/plugin-secrets/credentials'; import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry'; import { logger } from '@/lib/logger'; @@ -25,6 +27,17 @@ export async function GET(request: NextRequest) { const state = noThirdPartyTrafficState(); const policy = configManager.getPolicy(); const registry = await getPluginRegistry().catch(() => ({ plugins: [] })); + const enabled = registry.plugins.filter((plugin) => plugin.enabled); + // Plugins that run in the mode with the servers the admin entered for + // them; their manifest-fixed servers (Google Safe Browsing) stay off. + const modePlugins = enabled.filter((plugin) => pluginDeclaresNetwork(plugin) && pluginAllowedInMode(plugin)); + const pluginHosts = await Promise.all(modePlugins.map(async (plugin) => ({ + id: plugin.id, + name: plugin.name, + origins: await adminEnteredOrigins(plugin.id).catch(() => [] as string[]), + fixed: [...new Set(Object.values((plugin as { credentials?: Record }).credentials ?? {}) + .flatMap((spec) => (spec.origin ? [new URL(spec.origin).host] : [])))], + }))); return NextResponse.json( { @@ -35,10 +48,13 @@ export async function GET(request: NextRequest) { 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)) + reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env, plugins: pluginHosts }), + networkPlugins: enabled + .filter((plugin) => pluginDeclaresNetwork(plugin) && !pluginAllowedInMode(plugin)) .map((plugin) => ({ id: plugin.id, name: plugin.name })), + partlyOffPlugins: pluginHosts + .filter((plugin) => plugin.fixed.length > 0) + .map(({ id, name, fixed }) => ({ id, name, hosts: fixed })), }, { headers: { 'Cache-Control': 'no-store' } }, ); diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index 979eb4c26..3bf248e6f 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -3,7 +3,9 @@ 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'; +import { CREDENTIAL_ID_RE, type CredentialSpec } from '@/lib/plugin-secrets/credentials'; +import { credentialAllowedInMode } from '@/lib/outgoing-hosts'; +import { getPlugin } from '@/lib/admin/plugin-registry'; import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy'; import { bootGateResponse } from '@/lib/shared-store/boot-gate'; @@ -46,8 +48,7 @@ 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 thirdPartyOff = await isNoThirdPartyTrafficOn(); const creds = await getStalwartCredentials(request); if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 }); @@ -59,6 +60,14 @@ export async function POST(request: NextRequest) { if (!PLUGIN_ID_RE.test(pluginId) || !CREDENTIAL_ID_RE.test(credentialId) || !url) { return NextResponse.json({ error: 'plugin, credential and url are required', code: 'bad_request' }, { status: 400 }); } + // With third-party traffic off only a server the admin entered on the + // plugin's admin page stays reachable (a company link check); servers + // fixed in a manifest and accounts users connect are outside hosts (#903). + if (thirdPartyOff) { + const plugin = await getPlugin(pluginId); + const spec = (plugin as { credentials?: Record } | null)?.credentials?.[credentialId]; + if (!credentialAllowedInMode(spec)) return thirdPartyRefusal('The plugin proxy'); + } const capture = parseCapture(request.headers.get('x-bulwark-capture')); if (capture === null) { return NextResponse.json({ error: 'Invalid X-Bulwark-Capture header', code: 'bad_capture' }, { status: 400 }); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 39dd01460..8a91d2e4b 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -6,7 +6,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; -import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { pluginAllowedInMode } from '@/lib/outgoing-hosts'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -36,13 +36,14 @@ export async function GET(request: NextRequest) { 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. + // server must stay off outside hosts (#903); the rest run as usual, and + // so do those that reach only servers the admin entered for them. const noThirdParty = await isNoThirdPartyTrafficOn(); const plugins = [ ...devEntries.map(e => ({ ...e.plugin, dev: true })), ...installedEnabled.map(p => ({ ...p, dev: false })), - ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({ + ].filter(p => !noThirdParty || pluginAllowedInMode(p)).map(p => ({ id: p.id, name: p.name, version: p.version, diff --git a/components/admin/org/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx index 9280d8b48..7dc8ec34b 100644 --- a/components/admin/org/sections/third-party-section.tsx +++ b/components/admin/org/sections/third-party-section.tsx @@ -18,6 +18,8 @@ interface OutgoingHostsState { builtIn: OutgoingHost[]; reachable: ReachableHost[]; networkPlugins: { id: string; name: string }[]; + /** Running in the mode with the servers entered on their admin page; these manifest servers stay off. */ + partlyOffPlugins?: { id: string; name: string; hosts: string[] }[]; } const SIDE_LABEL: Record = { @@ -146,6 +148,11 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) { Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}. )} + {on && (state.partlyOffPlugins?.length ?? 0) > 0 && ( + + Running with only the servers entered on their admin page: {state.partlyOffPlugins!.map((p) => `${p.name} (${p.hosts.join(', ')} stays off)`).join('; ')}. + + )}

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

diff --git a/lib/__tests__/no-third-party-client.test.ts b/lib/__tests__/no-third-party-client.test.ts index 0445d9468..0aee3a27b 100644 --- a/lib/__tests__/no-third-party-client.test.ts +++ b/lib/__tests__/no-third-party-client.test.ts @@ -4,7 +4,7 @@ import { resyncWebPush, revokePushDevice, type PushDevice } from '@/lib/web-push import { doGetPublicKeyFromWKD } from '@/lib/plugin-sandbox/host-api'; import { applyLiteConfig } from '@/lib/lite-config'; import { setNoThirdPartyTraffic, noThirdPartyTraffic, parseNoThirdPartyEnv } from '@/lib/third-party-mode'; -import { isBuiltInHost, pluginAllowed, pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { credentialAllowedInMode, isBuiltInHost, pluginAllowed, pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; import { isPlaceholderHost } from '@/lib/admin/outgoing-hosts-registry'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -116,6 +116,19 @@ describe('plugins', () => { }); describe('config', () => { + it('lets a plugin run that reaches only servers the admin entered, and no other', () => { + setNoThirdPartyTraffic(true); + const admin = (extra: Record) => ({ label: 'x', scope: 'admin', secretKey: 's', ...extra }); + const linkCheck = { permissions: ['http:proxy'], credentials: { company: admin({ originKey: 'url' }), google: admin({ origin: 'https://safebrowsing.googleapis.com' }) } }; + const fixedOnly = { permissions: ['http:proxy'], credentials: { google: admin({ origin: 'https://safebrowsing.googleapis.com' }) } }; + const userAccount = { permissions: ['http:proxy'], credentials: { company: admin({ originKey: 'url' }), own: { label: 'x', scope: 'user' } } }; + const alsoFetches = { permissions: ['http:proxy', 'http:fetch'], httpOrigins: ['https://api.vendor.example'], credentials: { company: admin({ originKey: 'url' }) } }; + expect([linkCheck, fixedOnly, userAccount, alsoFetches].map(pluginAllowed)).toEqual([true, false, false, false]); + expect(credentialAllowedInMode(linkCheck.credentials.company)).toBe(true); + expect(credentialAllowedInMode(linkCheck.credentials.google)).toBe(false); + setNoThirdPartyTraffic(false); + }); + it('reads noThirdPartyTraffic from the Lite config.json', () => { expect(applyLiteConfig({}).noThirdPartyTraffic).toBe(false); expect(applyLiteConfig({ noThirdPartyTraffic: true }).noThirdPartyTraffic).toBe(true); diff --git a/lib/__tests__/no-third-party-server.test.ts b/lib/__tests__/no-third-party-server.test.ts index 16c7b136a..67cf93742 100644 --- a/lib/__tests__/no-third-party-server.test.ts +++ b/lib/__tests__/no-third-party-server.test.ts @@ -26,7 +26,7 @@ const registryPlugins: Record[] = []; vi.mock('@/lib/admin/plugin-registry', () => ({ getPluginRegistry: vi.fn(async () => ({ plugins: registryPlugins })), getThemeRegistry: vi.fn(async () => ({ themes: [] })), - getPlugin: vi.fn(async () => null), + getPlugin: vi.fn(async (id: string) => registryPlugins.find((p) => p.id === id) ?? null), getTheme: vi.fn(async () => null), })); vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) })); @@ -358,3 +358,59 @@ describe('review follow-ups', () => { } }); }); + +// A company link check is a server the admin entered on the plugin's admin +// page: it stays reachable with the switch on, like the JMAP server. The +// plugin used to be refused completely, and its domain warning with it. +describe('plugins reaching only servers the admin entered', () => { + const linkCheck = { + id: 'link-check', name: 'Link Check', version: '2.0.0', enabled: true, permissions: ['http:proxy'], + configSchema: { endpointUrl: { type: 'string', label: 'URL' }, token: { type: 'secret', label: 'Token' }, key: { type: 'secret', label: 'Key' } }, + credentials: { + company: { label: 'Company', scope: 'admin', inject: { type: 'none' }, secretKey: 'token', originKey: 'endpointUrl' }, + google: { label: 'Google', scope: 'admin', inject: { type: 'header', name: 'X-Goog-Api-Key' }, secretKey: 'key', origin: 'https://safebrowsing.googleapis.com' }, + }, + }; + const googleOnly = { + id: 'google-only', name: 'Google Only', version: '1.0.0', enabled: true, permissions: ['http:proxy'], + configSchema: { key: { type: 'secret', label: 'Key' } }, + credentials: { google: { label: 'Google', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'key', origin: 'https://safebrowsing.googleapis.com' } }, + }; + + it('reach the browser in the mode, unlike one with only fixed servers', async () => { + registryPlugins.push(linkCheck, googleOnly); + const { GET } = await import('@/app/api/plugins/route'); + await setMode(true); + const on = await (await GET(new NextRequest('https://webmail.example/api/plugins'))).json(); + expect(on.plugins.map((p: { id: string }) => p.id)).toEqual(['link-check']); + }); + + it('get the proxy for the admin-entered server only', async () => { + registryPlugins.push(linkCheck); + const { setPluginConfig } = await import('@/lib/admin/plugin-config'); + await setPluginConfig('link-check', 'endpointUrl', 'https://linkcheck.corp.example/check'); + fetchMock.mockResolvedValueOnce(new Response('{"verdict":"safe"}', { headers: { 'content-type': 'application/json' } })); + const { POST } = await import('@/app/api/plugin-proxy/route'); + await setMode(true, { features: { pluginsEnabled: true } }); + const company = await POST(new NextRequest('https://webmail.example/api/plugin-proxy?plugin=link-check&credential=company&url=https%3A%2F%2Flinkcheck.corp.example%2Fcheck', { method: 'POST' })); + expect((await company.clone().json().catch(() => ({}))).code).not.toBe('no-third-party-traffic'); + const google = await POST(new NextRequest('https://webmail.example/api/plugin-proxy?plugin=link-check&credential=google&url=https%3A%2F%2Fsafebrowsing.googleapis.com%2Fv5%2Fhashes%3Asearch', { method: 'POST' })); + expect(google.status).toBe(403); + expect((await google.json()).code).toBe('no-third-party-traffic'); + expect(calledHosts()).not.toContain('safebrowsing.googleapis.com'); + }); + + it('are listed on the Security page with the server the admin entered and the one that stays off', async () => { + registryPlugins.push(linkCheck, googleOnly); + const { setPluginConfig } = await import('@/lib/admin/plugin-config'); + await setPluginConfig('link-check', 'endpointUrl', 'https://linkcheck.corp.example/check'); + const { GET } = await import('@/app/api/admin/outgoing-hosts/route'); + await setMode(true); + const body = await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json(); + expect(body.reachable).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'Plugin Link Check', host: 'linkcheck.corp.example', side: 'server' }), + ])); + expect(body.networkPlugins).toEqual([{ id: 'google-only', name: 'Google Only' }]); + expect(body.partlyOffPlugins).toEqual([{ id: 'link-check', name: 'Link Check', hosts: ['safebrowsing.googleapis.com'] }]); + }); +}); diff --git a/lib/__tests__/plugin-host-features.test.ts b/lib/__tests__/plugin-host-features.test.ts index 3ed2adae4..5c76a65d5 100644 --- a/lib/__tests__/plugin-host-features.test.ts +++ b/lib/__tests__/plugin-host-features.test.ts @@ -10,6 +10,7 @@ import { render, cleanup, waitFor } from '@testing-library/react'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { usePolicyStore } from '@/stores/policy-store'; +import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; import { EXTERNAL_MAIL_WARNING_PLUGIN_ID, isHookHandedOver, @@ -30,11 +31,22 @@ beforeEach(() => setExternalMail({})); describe('snapshotHostFeatures', () => { it('has one flag per switch', () => { - expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false }); + expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }); setExternalMail({ senderLabel: true }); - expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false }); + expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }); setExternalMail({ recipientWarning: true }); - expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true }); + expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }); + }); + + // A link check switches Google Safe Browsing off in the mode instead of + // taking the refused requests for a service that is down. + it('says when third-party traffic is off, and while that is not known yet', () => { + setNoThirdPartyTraffic(true); + expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(true); + setNoThirdPartyTraffic('unknown'); + expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(true); + setNoThirdPartyTraffic(false); + expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(false); }); }); @@ -69,18 +81,22 @@ describe('host bridge', () => { const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never; const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' }); const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit; - expect(pending?.hostFeatures).toEqual({ externalSenderLabel: true, externalRecipientWarning: false }); + expect(pending?.hostFeatures).toEqual({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }); // Before init has gone out a change lands in the payload ... setExternalMail({}); - expect(pending?.hostFeatures).toEqual({ externalSenderLabel: false, externalRecipientWarning: false }); + expect(pending?.hostFeatures).toEqual({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }); // ... after it, as a host-change message. const sent: unknown[] = []; (instance as unknown as { pendingInit: unknown }).pendingInit = null; (instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m); setExternalMail({ recipientWarning: true }); - expect(sent).toEqual([{ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true } }]); + expect(sent).toEqual([{ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false } }]); + // The admin turns third-party traffic off: running plugins hear it. + setNoThirdPartyTraffic(true); + expect(sent[1]).toEqual({ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true } }); + setNoThirdPartyTraffic(false); instance.destroy(); }, 30_000); }); @@ -140,9 +156,10 @@ describe('api.host in the sandbox runtime', () => { const kept = api.host.features; const changes: unknown[] = []; api.host.onFeaturesChange((f) => changes.push({ ...(f as object) })); - post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false } }); + post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true } }); expect(kept.externalRecipientWarning).toBe(false); expect(kept.externalSenderLabel).toBe(true); - expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false }]); + expect((kept as { noThirdPartyTraffic?: boolean }).noThirdPartyTraffic).toBe(true); + expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true }]); }, 30_000); }); diff --git a/lib/admin/reachable-hosts.ts b/lib/admin/reachable-hosts.ts index 5782d6211..f801f0c1c 100644 --- a/lib/admin/reachable-hosts.ts +++ b/lib/admin/reachable-hosts.ts @@ -44,6 +44,11 @@ export interface ReachableInput { env: Record; /** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */ buildRelayUrl?: string; + /** + * Plugins that run in the mode and the servers the admin entered on their + * admin page (credentials with an originKey; lib/outgoing-hosts.ts). + */ + plugins?: { name: string; origins: string[] }[]; } const BRANDING_KEYS = [ @@ -51,7 +56,7 @@ const BRANDING_KEYS = [ 'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl', ] as const; -export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_RELAY_BASE_URL }: ReachableInput): ReachableHost[] { +export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_RELAY_BASE_URL, plugins = [] }: ReachableInput): ReachableHost[] { const out: ReachableHost[] = []; const seen = new Set(); const add = (kind: string, url: unknown, setIn: string, side: ReachableHost['side'], note?: string) => { @@ -92,6 +97,11 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R // Company calendars of Everyone and of every scope: this server fetches // them (app/api/fetch-ical by feed id), whatever the switch says. for (const feed of allFeeds(policy)) add('Company calendar', feed.url, `Organisation > Calendars (${feed.name})`, 'server'); + // A plugin's server the admin entered (a company link check): this server + // proxies its requests (app/api/plugin-proxy). + for (const plugin of plugins) { + for (const origin of plugin.origins) add(`Plugin ${plugin.name}`, origin, "The plugin's admin page", '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'); diff --git a/lib/outgoing-hosts.ts b/lib/outgoing-hosts.ts index 514e203c4..c58c8608b 100644 --- a/lib/outgoing-hosts.ts +++ b/lib/outgoing-hosts.ts @@ -80,10 +80,43 @@ export function avatarUrlAllowed(url: string | null | undefined, origin?: string } } +/** The parts of a manifest credential (lib/plugin-secrets/credentials.ts) the mode looks at. */ +export interface ModeCredential { + scope?: string; + origin?: string; + originKey?: string; +} + export interface NetworkDeclaringPlugin { permissions?: readonly string[]; httpOrigins?: readonly string[]; frameOrigins?: readonly string[]; + credentials?: Readonly>; +} + +/** + * A proxied credential stays usable in the no-third-party mode when the + * admin entered its server on the plugin's admin page (`originKey`): a host + * the deployment configured itself, like the JMAP server. A server fixed in + * the manifest (Google Safe Browsing) and the accounts users connect are + * outside hosts and stay off. + */ +export function credentialAllowedInMode(spec: ModeCredential | undefined): boolean { + return !!spec && spec.scope === 'admin' && typeof spec.originKey === 'string' && !spec.origin; +} + +/** + * A plugin whose only network access is the credential proxy with admin + * credentials, at least one of them on a server the admin entered. It runs + * in the mode; the proxy refuses its other credentials there. + */ +export function pluginReachesOnlyAdminHosts(plugin: NetworkDeclaringPlugin): boolean { + const permissions = plugin.permissions ?? []; + if (permissions.includes('http:fetch') || (plugin.httpOrigins?.length ?? 0) > 0 || (plugin.frameOrigins?.length ?? 0) > 0) return false; + const specs = Object.values(plugin.credentials ?? {}); + return specs.length > 0 + && specs.every((spec) => spec?.scope === 'admin') + && specs.some(credentialAllowedInMode); } /** @@ -102,7 +135,12 @@ export function pluginDeclaresNetwork(plugin: NetworkDeclaringPlugin): boolean { /** Error code a refused plugin carries (shown translated in Settings > Plugins). */ export const PLUGIN_REFUSED_ERROR = 'third-party-off'; +/** Whether a plugin may run while the mode is on (see pluginReachesOnlyAdminHosts). */ +export function pluginAllowedInMode(plugin: NetworkDeclaringPlugin): boolean { + return !pluginDeclaresNetwork(plugin) || pluginReachesOnlyAdminHosts(plugin); +} + /** Whether a plugin may run in this browser. */ export function pluginAllowed(plugin: NetworkDeclaringPlugin): boolean { - return !(noThirdPartyTraffic() && pluginDeclaresNetwork(plugin)); + return !noThirdPartyTraffic() || pluginAllowedInMode(plugin); } diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 1319cf2c6..893727ea1 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -67,7 +67,8 @@ import { requestPanel } from './panel-requests'; import { requestPageNavigation } from './page-navigation'; import { requestOpenEmail } from './open-email'; import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer'; -import { pluginAllowed, wkdAllowed } from '../outgoing-hosts'; +import { wkdAllowed } from '../outgoing-hosts'; +import { noThirdPartyTraffic } from '../third-party-mode'; import { resolveExternalLink } from '../plugin-hook-result'; /** @@ -698,8 +699,9 @@ async function readCapped(res: Response, limit: number): Promise { async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: PluginFetchInit) { // Such a plugin isn't started in the no-third-party mode; this is the - // second lock in case one runs anyway (#903). - if (!pluginAllowed(plugin)) throw new Error('Outside requests are off: this server does not contact outside services'); + // second lock in case one runs anyway (#903). A plugin the mode lets run + // (admin-entered servers through the proxy) has no outside fetch either. + if (noThirdPartyTraffic()) throw new Error('Outside requests are off: this server does not contact outside services'); if (typeof rawUrl !== 'string') throw new Error('url must be a string'); let url: URL; try { url = new URL(rawUrl); } catch { throw new Error('url must be absolute https://'); } diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index 488a9b7a1..63edc8cb3 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -9,22 +9,24 @@ // plugin id, one switch at a time. import { usePolicyStore } from '@/stores/policy-store'; +import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode'; import type { HostFeaturesView } from '../plugin-types'; import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; /** The external-mail-warning plugin, retired in favour of organisation.externalMail. */ export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning'; -/** The flags for an externalMail policy section (missing = both off). */ -export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail'] | undefined): HostFeaturesView { +/** The flags for an externalMail policy section (missing = both off) and the third-party mode. */ +export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail'] | undefined, thirdPartyOff = false): HostFeaturesView { return { externalSenderLabel: externalMail?.senderLabel === true, externalRecipientWarning: externalMail?.recipientWarning === true, + noThirdPartyTraffic: thirdPartyOff, }; } export function snapshotHostFeatures(): HostFeaturesView { - return hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail); + return hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()); } /** @@ -32,11 +34,11 @@ export function snapshotHostFeatures(): HostFeaturesView { * with the recipient warning, the banner and the new-mail toast (both about * incoming mail from outside) with the sender label. */ -const HOOK_FLAGS: Record = { +const HOOK_FLAGS: Record = { onBeforeEmailSend: 'externalRecipientWarning', onNewEmailReceived: 'externalSenderLabel', }; -const SLOT_FLAGS: Record = { +const SLOT_FLAGS: Record = { 'email-banner': 'externalSenderLabel', }; @@ -62,11 +64,14 @@ export function watchHostFeatures(onChange: (features: HostFeaturesView) => void if (watching) return; watching = true; lastKey = JSON.stringify(snapshotHostFeatures()); - usePolicyStore.subscribe(() => { + const check = () => { const features = snapshotHostFeatures(); const key = JSON.stringify(features); if (key === lastKey) return; lastKey = key; onChange(features); - }); + }; + usePolicyStore.subscribe(check); + // The mode is known once the config has been read, and an admin may turn it on later. + subscribeThirdPartyMode(check); } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 95f3cca62..eba4da5a2 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -150,13 +150,16 @@ function applySettings(next: Record): void { } // What api.host.features says: one object for the iframe's lifetime, // updated in place on 'host-change' like the settings. -const hostFeaturesState: HostFeaturesView = { externalSenderLabel: false, externalRecipientWarning: false }; +// noThirdPartyTraffic starts on, as the host's own mode does until it knows. +const hostFeaturesState: HostFeaturesView = { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: true }; const hostFeatureListeners = new Set<(features: HostFeaturesView) => void>(); function applyHostFeatures(next: HostFeaturesView | undefined): void { if (!next) return; hostFeaturesState.externalSenderLabel = next.externalSenderLabel === true; hostFeaturesState.externalRecipientWarning = next.externalRecipientWarning === true; + // A host older than 1.20 doesn't send it, and never let such a plugin run in the mode. + hostFeaturesState.noThirdPartyTraffic = next.noThirdPartyTraffic === true; for (const listener of hostFeatureListeners) { try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); } } diff --git a/lib/plugin-secrets/proxy.ts b/lib/plugin-secrets/proxy.ts index 766b49dd7..7130e0c0d 100644 --- a/lib/plugin-secrets/proxy.ts +++ b/lib/plugin-secrets/proxy.ts @@ -13,6 +13,7 @@ import { authHeaderFor, credentialOrigin, privateProxyOrigins, type CredentialIn import { getUserCredential, setUserCredentialSecret, userKey, type VaultUser } from './vault'; import { declaredUserCredential } from './known-plugins'; import { isStoredDataUnavailable } from '@/lib/stored-data-errors'; +import { credentialAllowedInMode } from '@/lib/outgoing-hosts'; /** Largest request body the proxy forwards (one upload chunk). */ export const MAX_PROXY_REQUEST_BYTES = 100 * 1024 * 1024; @@ -119,6 +120,25 @@ export async function adminCredentialOrigins(pluginId: string): Promise (origin ? [origin] : [])); } +/** + * The servers the admin entered for a plugin's credentials (originKey), set + * or not yet set up with a secret: what stays reachable for it with + * third-party traffic off (Organisation > Security). + */ +export async function adminEnteredOrigins(pluginId: string): Promise { + const plugin = await getPlugin(pluginId); + const specs = (plugin as { credentials?: Record } | null)?.credentials; + if (!plugin || !plugin.enabled || !specs) return []; + const config = await getPluginConfig(pluginId); + const out = new Set(); + for (const spec of Object.values(specs)) { + if (spec.scope !== 'admin' || !credentialAllowedInMode(spec) || !spec.originKey) continue; + const origin = credentialOrigin(config[spec.originKey]); + if (origin) out.add(origin); + } + return [...out]; +} + async function adminCredentials(pluginId: string): Promise<{ credentialId: string; origin: string | null }[]> { const plugin = await getPlugin(pluginId); const specs = (plugin as { credentials?: Record } | null)?.credentials; diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 0fcdd15a0..1e8527570 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -876,6 +876,14 @@ export interface HostFeaturesView { externalSenderLabel: boolean; /** The admin turned on the confirm before sending outside the organisation (organisation.externalMail.recipientWarning). */ externalRecipientWarning: boolean; + /** + * Bulwark 1.20 and later: "no third-party traffic" is on (or not known + * yet). Outside services are off: the proxy refuses servers fixed in a + * manifest and only reaches those the admin entered on the plugin's admin + * page. A plugin switches such a service off instead of treating it as + * down. + */ + noThirdPartyTraffic: boolean; } /** One setting `api.app.searchSettings` found, in the user's language. */