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 01/21] 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 && (