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:
Linus Rath
2026-11-01 08:39:35 +01:00
parent b3369c7e78
commit e73a1b375f
14 changed files with 238 additions and 33 deletions
+20 -4
View File
@@ -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' } },
);
+12 -3
View File
@@ -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 });
+4 -3
View File
@@ -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>
+14 -1
View File
@@ -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);
+57 -1
View File
@@ -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'] }]);
});
});
+25 -8
View File
@@ -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);
});
+11 -1
View File
@@ -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
View File
@@ -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);
}
+5 -3
View File
@@ -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://'); }
+12 -7
View File
@@ -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);
}
+4 -1
View File
@@ -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); }
}
+20
View File
@@ -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;
+8
View File
@@ -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. */