From 553f1e9149169cef18b01e761bb6740abb15b9fb Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 02:19:06 +0100
Subject: [PATCH] feat: add a switch that stops all third-party traffic #903
---
app/(main)/admin/_pages/telemetry.tsx | 8 +-
app/(main)/admin/_pages/version.tsx | 6 +
app/api/admin/config/route.ts | 12 +
app/api/admin/marketplace/[slug]/route.ts | 5 +
app/api/admin/marketplace/route.ts | 8 +
app/api/admin/marketplace/updates/route.ts | 9 +
app/api/admin/telemetry/route.ts | 6 +
app/api/admin/version/route.ts | 2 +
app/api/config/route.ts | 3 +
app/api/connector/capabilities/route.ts | 3 +-
app/api/favicon/route.ts | 5 +
app/api/plugin-proxy/route.ts | 3 +
app/api/plugins/route.ts | 7 +-
app/api/translate/route.ts | 5 +
components/calendar/event-detail-popover.tsx | 7 +-
components/push-notification-prompt.tsx | 3 +
components/settings/message-list-settings.tsx | 5 +-
components/settings/notification-settings.tsx | 12 +-
components/settings/plugins-settings.tsx | 5 +-
components/ui/__tests__/avatar.test.tsx | 14 +
components/ui/avatar.tsx | 10 +-
hooks/use-config.ts | 7 +
lib/__tests__/favicon-route-inflight.test.ts | 6 +-
lib/__tests__/fixtures/config.ts | 1 +
lib/__tests__/no-third-party-client.test.ts | 123 ++++++++
lib/__tests__/no-third-party-server.test.ts | 273 ++++++++++++++++++
lib/__tests__/plugin-sandbox-loader.test.ts | 19 ++
lib/admin/csp-frame-origins.ts | 7 +
lib/admin/marketplace-directory.ts | 21 +-
lib/admin/third-party-mode.ts | 37 +++
lib/admin/types.ts | 7 +
lib/config-validation.ts | 1 +
lib/lite-config.ts | 2 +
lib/outgoing-hosts.ts | 255 ++++++++++++++++
lib/plugin-sandbox/host-api.ts | 6 +
lib/plugin-sandbox/loader.ts | 8 +
lib/push-relays.ts | 18 +-
lib/telemetry/sender.ts | 2 +
lib/telemetry/state.ts | 9 +-
lib/third-party-mode.ts | 26 ++
lib/version-check/sender.ts | 9 +
lib/web-push.ts | 16 +-
locales/de/common.json | 6 +
locales/en/common.json | 6 +
scripts/lite/build.mjs | 1 +
scripts/lite/lib.mjs | 4 +
46 files changed, 978 insertions(+), 30 deletions(-)
create mode 100644 lib/__tests__/no-third-party-client.test.ts
create mode 100644 lib/__tests__/no-third-party-server.test.ts
create mode 100644 lib/admin/third-party-mode.ts
create mode 100644 lib/outgoing-hosts.ts
create mode 100644 lib/third-party-mode.ts
diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx
index a745ac45c..796a1564d 100644
--- a/app/(main)/admin/_pages/telemetry.tsx
+++ b/app/(main)/admin/_pages/telemetry.tsx
@@ -15,7 +15,7 @@ import { cn } from '@/lib/utils';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
- consentSource: 'env' | 'file';
+ consentSource: 'no-third-party' | 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
@@ -132,14 +132,16 @@ export function TelemetryPage() {
);
}
- const envOverridden = status.consentSource === 'env';
+ const noThirdParty = status.consentSource === 'no-third-party';
+ const envOverridden = status.consentSource === 'env' || noThirdParty;
const isOn = status.consent === 'on';
const consentDescription = [
status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!',
status.consent === 'off' && 'Heartbeats are off (default).',
- envOverridden && 'Locked by the BULWARK_TELEMETRY env var.',
+ noThirdParty && 'Locked off: no third-party traffic is on (Organisation > Security).',
+ envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.',
].filter(Boolean).join(' ');
return (
diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx
index e9dcd6d0c..7a700786a 100644
--- a/app/(main)/admin/_pages/version.tsx
+++ b/app/(main)/admin/_pages/version.tsx
@@ -24,6 +24,7 @@ interface VersionAdminStatus {
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
+ disabledByNoThirdPartyTraffic?: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
@@ -206,6 +207,11 @@ export function VersionPage() {
{data.disabledByEnv ? 'Yes' : 'No'}
+ {data.disabledByNoThirdPartyTraffic && (
+
+ Yes
+
+ )}
diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts
index 2981b5b8a..33e3cd203 100644
--- a/app/api/admin/config/route.ts
+++ b/app/api/admin/config/route.ts
@@ -10,6 +10,7 @@ import { logger } from '@/lib/logger';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url';
+import { parseNoThirdPartyEnv } from '@/lib/third-party-mode';
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
@@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) {
if (problem) return NextResponse.json({ error: problem }, { status: 400 });
}
+ // The no-third-party switch is fail-closed: an "on" from the environment
+ // can't be undone from the panel (lib/admin/third-party-mode.ts).
+ if ('noThirdPartyTraffic' in updates) {
+ if (typeof updates.noThirdPartyTraffic !== 'boolean') {
+ return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 });
+ }
+ if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) {
+ return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 });
+ }
+ }
+
// Enum keys only accept their declared values; a stray string would
// otherwise be persisted and fail closed (or open) at the read site.
for (const [key, value] of Object.entries(updates)) {
diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts
index 4df6da909..f35fd899b 100644
--- a/app/api/admin/marketplace/[slug]/route.ts
+++ b/app/api/admin/marketplace/[slug]/route.ts
@@ -12,6 +12,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
historyFor,
installedExtensions,
installedStateOf,
@@ -65,6 +67,9 @@ export async function GET(
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const { slug } = await params;
const requested = request.nextUrl.searchParams.get('version');
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 50404e882..19cfb739e 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -32,6 +32,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
installedExtensions,
installedStateOf,
readBundleBytes,
@@ -57,6 +59,9 @@ export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const directoryUrl = await getDirectoryUrl();
const query: Record = {};
@@ -105,6 +110,9 @@ export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const ip = getClientIP(request);
const { slug, version, type, force } = await request.json();
diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts
index 52b14e8cd..7d2643452 100644
--- a/app/api/admin/marketplace/updates/route.ts
+++ b/app/api/admin/marketplace/updates/route.ts
@@ -8,6 +8,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
installedExtensions,
v2Url,
type DirectoryAdvisory,
@@ -43,6 +45,13 @@ export async function GET(request: NextRequest) {
const list = [...installed].map(([id, e]) => ({ id, ...e }));
const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version }));
+ // The installed list is still useful with the official directory off (#903).
+ if (await directoryOff()) {
+ return NextResponse.json(
+ { installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ }
const directoryUrl = await getDirectoryUrl();
const channel = getDirectoryChannel();
let answer: UpdatesAnswer = EMPTY;
diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts
index 192e64b31..daa5c19f8 100644
--- a/app/api/admin/telemetry/route.ts
+++ b/app/api/admin/telemetry/route.ts
@@ -81,6 +81,12 @@ export async function POST(request: NextRequest) {
{ status: 409 },
);
}
+ if (source === 'no-third-party') {
+ return NextResponse.json(
+ { error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' },
+ { status: 409 },
+ );
+ }
if (body.consent !== 'on' && body.consent !== 'off') {
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
}
diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts
index 2c63a7b33..004db83d2 100644
--- a/app/api/admin/version/route.ts
+++ b/app/api/admin/version/route.ts
@@ -9,6 +9,7 @@ import {
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* GET /api/admin/version
@@ -27,6 +28,7 @@ export async function GET(request: NextRequest) {
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
+ disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index 1faf393c3..d2293318d 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -11,6 +11,7 @@ import {
pickRequestHost,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* Runtime configuration endpoint
@@ -98,6 +99,8 @@ export async function GET(request: NextRequest) {
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get('parentOrigin', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')),
+ // Browser-side switches of lib/outgoing-hosts.ts read this (#903).
+ noThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
},
{
// Runtime settings can change without new asset URLs. Prevent stale
diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts
index 4ff6fd1a1..2235298b9 100644
--- a/app/api/connector/capabilities/route.ts
+++ b/app/api/connector/capabilities/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
import { TARGET_NAMES } from '@/lib/connector/registry';
/**
@@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry';
export async function GET() {
await configManager.ensureLoaded();
- if (!configManager.get('connectorEnabled', true)) {
+ if (!configManager.get('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) {
return new NextResponse(null, { status: 404 });
}
diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts
index 7dc48fab8..311757fbe 100644
--- a/app/api/favicon/route.ts
+++ b/app/api/favicon/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
const CACHE_MAX_SIZE = 1000;
@@ -439,6 +440,10 @@ function evictOldest() {
}
export async function GET(request: NextRequest) {
+ // Sender icons come from DuckDuckGo: off, before anything else, while the
+ // server must not contact outside services (#903).
+ if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons');
+
const domain = request.nextUrl.searchParams.get('domain');
if (!domain || !isValidDomain(domain)) {
diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts
index 5da089c90..2715160e7 100644
--- a/app/api/plugin-proxy/route.ts
+++ b/app/api/plugin-proxy/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials';
@@ -41,6 +42,8 @@ export async function POST(request: NextRequest) {
if (!configManager.getPolicy().features.pluginsEnabled) {
return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 });
}
+ // Every proxied request goes to a host outside this deployment (#903).
+ if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy');
const creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts
index 238ecc0e7..b5652688a 100644
--- a/app/api/plugins/route.ts
+++ b/app/api/plugins/route.ts
@@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
+import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
/**
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
@@ -32,11 +34,14 @@ export async function GET(request: NextRequest) {
// plugin without uninstalling it first.
const devIds = new Set(devEntries.map(e => e.plugin.id));
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
+ // Plugins that declare network access never reach a browser while the
+ // server must stay off outside hosts (#903); the rest run as usual.
+ const noThirdParty = await isNoThirdPartyTrafficOn();
const plugins = [
...devEntries.map(e => ({ ...e.plugin, dev: true })),
...installedEnabled.map(p => ({ ...p, dev: false })),
- ].map(p => ({
+ ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({
id: p.id,
name: p.name,
version: p.version,
diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts
index cdf729636..7507303f6 100644
--- a/app/api/translate/route.ts
+++ b/app/api/translate/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { readFileEnv } from "@/lib/read-file-env";
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
@@ -284,6 +285,10 @@ export async function POST(request: NextRequest) {
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
+ // MyMemory is a public service; the admin's own LibreTranslate is not (#903).
+ if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) {
+ return thirdPartyRefusal('Translation through MyMemory');
+ }
if (!text) {
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index 4ab0eba77..0df3ef58d 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -25,6 +25,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
+import { mapsLinksAllowed } from "@/lib/outgoing-hosts";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { eventToView } from "@/lib/plugin-projection";
@@ -160,7 +161,9 @@ export function EventDetailPopover({
const locationHref = useMemo(() => {
if (!locationName) return null;
const action = locationAction(locationName, meeting);
- return action.kind === "url" ? action.uri : mapsUrl(action.query);
+ if (action.kind === "url") return action.uri;
+ // No Google Maps link while third-party traffic is off (#903).
+ return mapsLinksAllowed() ? mapsUrl(action.query) : null;
}, [locationName, meeting]);
const [locationCopied, setLocationCopied] = useState(false);
const copiedTimer = useRef | null>(null);
@@ -493,7 +496,7 @@ export function EventDetailPopover({
href={locationHref ?? undefined}
target="_blank"
rel="noreferrer"
- className="text-sm text-primary hover:underline min-w-0 break-words"
+ className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")}
title={locationName}
>
{/^https?:\/\//i.test(locationName)
diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx
index da584f836..47b5a70c3 100644
--- a/components/push-notification-prompt.tsx
+++ b/components/push-notification-prompt.tsx
@@ -121,6 +121,7 @@ export function PushNotificationPrompt() {
);
const accountId = client?.getAccountId() ?? null;
+ // Empty when no relay may be used (third-party traffic off, no own relay).
const relayBaseUrl = resolveActiveRelayUrl(policy, userPushRelayUrl);
const dismissForSession = useCallback((id: string) => {
@@ -252,6 +253,7 @@ export function PushNotificationPrompt() {
|| wasDismissed(accountId)
|| !isWebPushSupported()
|| Notification.permission === "denied"
+ || !relayBaseUrl
) {
return;
}
@@ -284,6 +286,7 @@ export function PushNotificationPrompt() {
pathname,
policyLoaded,
pwaPromptVisible,
+ relayBaseUrl,
sessionDismissedAccountIds,
]);
diff --git a/components/settings/message-list-settings.tsx b/components/settings/message-list-settings.tsx
index 2f730c958..7d47fc158 100644
--- a/components/settings/message-list-settings.tsx
+++ b/components/settings/message-list-settings.tsx
@@ -13,6 +13,7 @@ import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { IS_LITE } from '@/lib/lite';
+import { senderFaviconsAllowed } from '@/lib/outgoing-hosts';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { MessageListOrderSettings } from './message-list-order-settings';
import { Button } from '@/components/ui/button';
@@ -216,13 +217,13 @@ export function MessageListSettings() {
{/* Sender favicons come from the /api/favicon proxy, absent in the static Lite build (components/ui/avatar.tsx). */}
- {!IS_LITE && (
+ {!IS_LITE && senderFaviconsAllowed() && (
updateSetting('senderFavicons', checked)} />
)}
- {!IS_LITE && senderFavicons && (
+ {!IS_LITE && senderFaviconsAllowed() && senderFavicons && (
@@ -432,11 +438,11 @@ export function NotificationSettings() {
{relayChoiceFixed ? (
- {activeRelayLabel}
+ {noRelay ? tThirdParty('relay_none') : activeRelayLabel}
) : (