mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
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.
This commit is contained in:
@@ -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<string, CredentialSpec> }).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' } },
|
||||
);
|
||||
|
||||
@@ -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<string, CredentialSpec> } | 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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReachableHost['side'], string> = {
|
||||
@@ -146,6 +148,11 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}.
|
||||
</ConfigNote>
|
||||
)}
|
||||
{on && (state.partlyOffPlugins?.length ?? 0) > 0 && (
|
||||
<ConfigNote>
|
||||
Running with only the servers entered on their admin page: {state.partlyOffPlugins!.map((p) => `${p.name} (${p.hosts.join(', ')} stays off)`).join('; ')}.
|
||||
</ConfigNote>
|
||||
)}
|
||||
|
||||
<div data-testid="third-party-reachable">
|
||||
<h4 className="text-sm font-medium text-foreground">{on ? 'Still reachable' : 'Stays reachable with the switch on'}</h4>
|
||||
|
||||
@@ -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<string, unknown>) => ({ 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);
|
||||
|
||||
@@ -26,7 +26,7 @@ const registryPlugins: Record<string, unknown>[] = [];
|
||||
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'] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface ReachableInput {
|
||||
env: Record<string, string | undefined>;
|
||||
/** 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<string>();
|
||||
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');
|
||||
|
||||
+39
-1
@@ -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<Record<string, ModeCredential>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -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<ArrayBuffer> {
|
||||
|
||||
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://'); }
|
||||
|
||||
@@ -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<string, keyof HostFeaturesView> = {
|
||||
const HOOK_FLAGS: Record<string, 'externalSenderLabel' | 'externalRecipientWarning'> = {
|
||||
onBeforeEmailSend: 'externalRecipientWarning',
|
||||
onNewEmailReceived: 'externalSenderLabel',
|
||||
};
|
||||
const SLOT_FLAGS: Record<string, keyof HostFeaturesView> = {
|
||||
const SLOT_FLAGS: Record<string, 'externalSenderLabel' | 'externalRecipientWarning'> = {
|
||||
'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);
|
||||
}
|
||||
|
||||
@@ -150,13 +150,16 @@ function applySettings(next: Record<string, unknown>): 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); }
|
||||
}
|
||||
|
||||
@@ -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<string[]
|
||||
return (await adminCredentials(pluginId)).flatMap(({ origin }) => (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<string[]> {
|
||||
const plugin = await getPlugin(pluginId);
|
||||
const specs = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials;
|
||||
if (!plugin || !plugin.enabled || !specs) return [];
|
||||
const config = await getPluginConfig(pluginId);
|
||||
const out = new Set<string>();
|
||||
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<string, CredentialSpec> } | null)?.credentials;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user