mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: add a switch that stops all third-party traffic #903
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
{data.disabledByNoThirdPartyTraffic && (
|
||||
<SettingItem label="Off: no third-party traffic" description="NO_THIRD_PARTY_TRAFFIC is on, so this server never asks the version endpoint. See Organisation > Security.">
|
||||
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">Yes</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, string | undefined> = {};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>('parentOrigin', ''),
|
||||
sourceCodeUrl: httpUrlOrEmpty(configManager.get<string>('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
|
||||
|
||||
@@ -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<boolean>('connectorEnabled', true)) {
|
||||
if (!configManager.get<boolean>('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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)
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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). */}
|
||||
<SettingsSection id="senders" title={tPage('sections.senders')}>
|
||||
{!IS_LITE && (
|
||||
{!IS_LITE && senderFaviconsAllowed() && (
|
||||
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
|
||||
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{!IS_LITE && senderFavicons && (
|
||||
{!IS_LITE && senderFaviconsAllowed() && senderFavicons && (
|
||||
<SettingItem nested label={tAdvanced('sender_favicons.refresh_label')} description={tAdvanced('sender_favicons.refresh_description')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@/lib/web-push';
|
||||
import type { PushDevice } from '@/lib/web-push';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import {
|
||||
resolveActiveRelayUrl,
|
||||
resolvePushRelayOptions,
|
||||
@@ -50,6 +51,7 @@ type DevicesState =
|
||||
export function NotificationSettings() {
|
||||
const t = useTranslations('settings.notifications');
|
||||
const tAppearance = useTranslations('settings.appearance');
|
||||
const tThirdParty = useTranslations('third_party');
|
||||
const {
|
||||
emailNotificationsEnabled,
|
||||
emailNotificationSound,
|
||||
@@ -83,6 +85,8 @@ export function NotificationSettings() {
|
||||
const relayOptions = resolvePushRelayOptions(policy);
|
||||
const activeRelayUrl = resolveActiveRelayUrl(policy, pushRelayUrl);
|
||||
const relayChoiceFixed = pushRelayLocked || relayOptions.length < 2;
|
||||
// Third-party traffic off and no relay of the organisation's own (#903).
|
||||
const noRelay = !activeRelayUrl;
|
||||
const activeRelayLabel =
|
||||
relayOptions.find((option) => option.url === activeRelayUrl)?.label ?? activeRelayUrl;
|
||||
|
||||
@@ -140,6 +144,8 @@ export function NotificationSettings() {
|
||||
// components/service-worker-registration.tsx), not a browser limitation.
|
||||
const statusDescription = pushStatus.kind === 'unsupported'
|
||||
? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
|
||||
: noRelay && !pushEnabled
|
||||
? tThirdParty('push_needs_relay')
|
||||
: busy
|
||||
? t('push.status_busy')
|
||||
: pushEnabled
|
||||
@@ -420,7 +426,7 @@ export function NotificationSettings() {
|
||||
<ToggleSwitch
|
||||
checked={pushEnabled}
|
||||
onChange={(checked) => void (checked ? handleEnablePush() : handleDisablePush())}
|
||||
disabled={busy || pushStatus.kind === 'unsupported' || !client}
|
||||
disabled={busy || pushStatus.kind === 'unsupported' || !client || (noRelay && !pushEnabled)}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
@@ -432,11 +438,11 @@ export function NotificationSettings() {
|
||||
<SettingItem
|
||||
advanced
|
||||
label={t('push.relay_label')}
|
||||
description={pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
|
||||
description={pushRelayLocked ? t('push.relay_locked_desc') : noThirdPartyTraffic() ? tThirdParty('relay_desc') : t('push.relay_desc')}
|
||||
locked={pushRelayLocked}
|
||||
>
|
||||
{relayChoiceFixed ? (
|
||||
<span className="text-sm text-muted-foreground">{activeRelayLabel}</span>
|
||||
<span className="text-sm text-muted-foreground">{noRelay ? tThirdParty('relay_none') : activeRelayLabel}</span>
|
||||
) : (
|
||||
<Select
|
||||
value={activeRelayUrl}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { PluginPermissionList } from '@/components/plugins/plugin-permission-list';
|
||||
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
||||
import { effectiveSettings } from '@/lib/plugin-settings';
|
||||
import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts';
|
||||
import { lookup } from '@/lib/plugin-sandbox/plugin-i18n';
|
||||
import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog';
|
||||
|
||||
@@ -213,12 +214,14 @@ interface PluginCardProps {
|
||||
|
||||
function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) {
|
||||
const t = useTranslations('plugins.settings');
|
||||
const tThirdParty = useTranslations('third_party');
|
||||
const locale = useLocale();
|
||||
const isManaged = Boolean(plugin.managed);
|
||||
const awaiting = !isManaged && plugin.approvalStatus === 'pending';
|
||||
const denied = !isManaged && plugin.approvalStatus === 'denied';
|
||||
const errorText = plugin.error
|
||||
? ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error
|
||||
? plugin.error === PLUGIN_REFUSED_ERROR ? tThirdParty('plugin_refused')
|
||||
: ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useContactStore } from '@/stores/contact-store';
|
||||
import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
|
||||
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders initials from full name', () => {
|
||||
@@ -230,4 +231,17 @@ describe('Avatar', () => {
|
||||
expect(stamp).toBeGreaterThan(0);
|
||||
expect(img()?.getAttribute('src')).toBe(`/api/favicon?domain=example.cz&v=${stamp}`);
|
||||
});
|
||||
|
||||
// #903: no sender icons, and so no /api/favicon request, while third-party traffic is off.
|
||||
it('shows initials instead of the sender favicon in the no-third-party mode', () => {
|
||||
useFaviconRefreshStore.setState({ refreshedAt: 0 });
|
||||
setNoThirdPartyTraffic(true);
|
||||
try {
|
||||
const { container } = render(<Avatar name="Jan" email="jan@example.cz" />);
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(container.textContent).toBe('JA');
|
||||
} finally {
|
||||
setNoThirdPartyTraffic(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useContactPhotoSrc } from "@/hooks/use-contact-photo";
|
||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts";
|
||||
import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
@@ -126,7 +127,7 @@ const CUSTOM_AVATARS: Record<string, string> = IS_DEV ? {
|
||||
// Mock-server-only: for personal-domain emails, deterministically pick a randomuser.me portrait.
|
||||
// Returns null for ~30% of addresses so not everyone has a photo.
|
||||
function getProfilePictureUrl(email: string, domain: string, devMode: boolean, name?: string): string | null {
|
||||
if (!devMode) return null;
|
||||
if (!devMode || !sampleAvatarsAllowed()) return null;
|
||||
if (!PERSONAL_DOMAINS.has(domain)) return null;
|
||||
const h = emailHash(email);
|
||||
if (h % 10 < 3) return null; // ~30% get no photo
|
||||
@@ -237,12 +238,13 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
|
||||
};
|
||||
|
||||
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
|
||||
// Sender favicons come from the /api/favicon proxy, absent in the static build.
|
||||
// Sender favicons come from the /api/favicon proxy, absent in the static
|
||||
// build and off while third-party traffic is (#903).
|
||||
const showFavicon =
|
||||
!IS_LITE && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
!IS_LITE && senderFaviconsAllowed() && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
|
||||
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const customAvatar = devMode && email && sampleAvatarsAllowed() ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/lib/lite';
|
||||
import { applyLiteConfig, liteStalwartDefaults } from '@/lib/lite-config';
|
||||
import { isConfigData } from '@/lib/config-validation';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
|
||||
export interface ConfigData {
|
||||
appName: string;
|
||||
@@ -46,6 +47,8 @@ export interface ConfigData {
|
||||
embeddedMode: boolean;
|
||||
parentOrigin: string;
|
||||
sourceCodeUrl: string;
|
||||
/** Stay off every built-in outside host (#903; lib/outgoing-hosts.ts). */
|
||||
noThirdPartyTraffic: boolean;
|
||||
}
|
||||
|
||||
interface AppConfig extends ConfigData {
|
||||
@@ -123,6 +126,7 @@ export async function fetchConfig(): Promise<ConfigData> {
|
||||
configPromise = (IS_LITE ? fetchLiteConfig() : fetchServerConfig())
|
||||
.then((data) => {
|
||||
configCache = data;
|
||||
setNoThirdPartyTraffic(data.noThirdPartyTraffic === true);
|
||||
// Fetch admin policy alongside config (non-blocking)
|
||||
usePolicyStore.getState().fetchPolicy();
|
||||
return data;
|
||||
@@ -181,6 +185,7 @@ export function useConfig(): AppConfig {
|
||||
embeddedMode: configCache?.embeddedMode || false,
|
||||
parentOrigin: configCache?.parentOrigin || '',
|
||||
sourceCodeUrl: configCache?.sourceCodeUrl || '',
|
||||
noThirdPartyTraffic: configCache?.noThirdPartyTraffic ?? false,
|
||||
isLoading: !configCache,
|
||||
error: null,
|
||||
});
|
||||
@@ -226,6 +231,7 @@ export function useConfig(): AppConfig {
|
||||
embeddedMode: configCache.embeddedMode,
|
||||
parentOrigin: configCache.parentOrigin,
|
||||
sourceCodeUrl: configCache.sourceCodeUrl,
|
||||
noThirdPartyTraffic: configCache.noThirdPartyTraffic,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
@@ -272,6 +278,7 @@ export function useConfig(): AppConfig {
|
||||
embeddedMode: data.embeddedMode,
|
||||
parentOrigin: data.parentOrigin,
|
||||
sourceCodeUrl: data.sourceCodeUrl,
|
||||
noThirdPartyTraffic: data.noThirdPartyTraffic,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -30,8 +30,8 @@ describe('favicon route upstream concurrency', () => {
|
||||
const inFlight = Array.from({ length: 16 }, (_, i) =>
|
||||
GET(new NextRequest(`https://webmail.example/api/favicon?domain=site${i}.example`)),
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(16);
|
||||
// The route reads the no-third-party switch from config first (#903).
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(16));
|
||||
|
||||
const over = await GET(new NextRequest('https://webmail.example/api/favicon?domain=site99.example'));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(16);
|
||||
@@ -42,7 +42,7 @@ describe('favicon route upstream concurrency', () => {
|
||||
for (const release of pending.splice(0)) release();
|
||||
await Promise.all(inFlight);
|
||||
const later = GET(new NextRequest('https://webmail.example/api/favicon?domain=site100.example'));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(17);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(17));
|
||||
for (const release of pending.splice(0)) release();
|
||||
expect((await later).status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
|
||||
embeddedMode: false,
|
||||
parentOrigin: '',
|
||||
sourceCodeUrl: '',
|
||||
noThirdPartyTraffic: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_RELAY_BASE_URL, resolveActiveRelayUrl, resolveDefaultRelayUrl, resolvePushRelayOptions } from '@/lib/push-relays';
|
||||
import { 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, isPlaceholderHost, pluginAllowed, pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// The browser half of the "no third-party traffic" mode (#903).
|
||||
|
||||
const fetchSpy = vi.fn(async () => new Response('{}'));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockClear();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setNoThirdPartyTraffic(false);
|
||||
});
|
||||
|
||||
const ownRelay = { label: 'Company', url: 'https://push.company.example' };
|
||||
|
||||
describe('push relays', () => {
|
||||
it('drop the built-in relay in the mode and keep the admin\'s', () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(resolvePushRelayOptions({})).toEqual([]);
|
||||
expect(resolvePushRelayOptions({ pushRelays: [ownRelay] })).toEqual([ownRelay]);
|
||||
setNoThirdPartyTraffic(false);
|
||||
expect(resolvePushRelayOptions({ pushRelays: [ownRelay] }).map((o) => o.url)).toEqual([DEFAULT_RELAY_BASE_URL, ownRelay.url]);
|
||||
});
|
||||
|
||||
it('fall back to the admin\'s relay, or to none, instead of the built-in one', () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(resolveDefaultRelayUrl({ pushRelays: [ownRelay] })).toBe(ownRelay.url);
|
||||
expect(resolveDefaultRelayUrl({})).toBe('');
|
||||
// A user's saved pick of the built-in relay is not honoured either.
|
||||
expect(resolveActiveRelayUrl({}, DEFAULT_RELAY_BASE_URL)).toBe('');
|
||||
expect(resolveActiveRelayUrl({ pushRelays: [ownRelay] }, DEFAULT_RELAY_BASE_URL)).toBe(ownRelay.url);
|
||||
// A configured pushRelayUrl pointing at the built-in host doesn't sneak it back.
|
||||
expect(resolveDefaultRelayUrl({ pushRelayUrl: DEFAULT_RELAY_BASE_URL })).toBe('');
|
||||
setNoThirdPartyTraffic(false);
|
||||
expect(resolveDefaultRelayUrl({})).toBe(DEFAULT_RELAY_BASE_URL);
|
||||
});
|
||||
|
||||
it('never get a request to the built-in relay in the mode', async () => {
|
||||
const client = { destroyPushSubscription: vi.fn(async () => {}) } as unknown as IJMAPClient;
|
||||
const device: PushDevice = { id: 'p1', deviceClientId: 'abc', expires: null, types: null, isThisDevice: false, relayStatus: 'unknown' };
|
||||
|
||||
setNoThirdPartyTraffic(true);
|
||||
await revokePushDevice({ client, device, relayBaseUrl: DEFAULT_RELAY_BASE_URL });
|
||||
expect(client.destroyPushSubscription).toHaveBeenCalledWith('p1');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
setNoThirdPartyTraffic(false);
|
||||
await revokePushDevice({ client, device, relayBaseUrl: DEFAULT_RELAY_BASE_URL });
|
||||
expect(fetchSpy).toHaveBeenCalledWith(`${DEFAULT_RELAY_BASE_URL}/api/push/register/abc`, { method: 'DELETE' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WKD key lookup', () => {
|
||||
it('asks the recipient\'s domain only with the mode off', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(await doGetPublicKeyFromWKD('joe@vendor.example')).toMatchObject({ status: 'ERROR' });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
setNoThirdPartyTraffic(false);
|
||||
fetchSpy.mockImplementation(async () => new Response('', { status: 404 }));
|
||||
await doGetPublicKeyFromWKD('joe@vendor.example');
|
||||
expect(String((fetchSpy.mock.calls[0] as unknown[])[0])).toMatch(/^https:\/\/openpgpkey\.vendor\.example\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugins', () => {
|
||||
it('that declare network access are refused in the mode, the rest run', () => {
|
||||
const offline = { permissions: ['email:read', 'http:post'] };
|
||||
const fetcher = { permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] };
|
||||
const proxied = { permissions: ['http:proxy'] };
|
||||
const framer = { permissions: [], frameOrigins: ['https://www.youtube-nocookie.com'] };
|
||||
expect([offline, fetcher, proxied, framer].map(pluginDeclaresNetwork)).toEqual([false, true, true, true]);
|
||||
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect([offline, fetcher, proxied, framer].map(pluginAllowed)).toEqual([true, false, false, false]);
|
||||
setNoThirdPartyTraffic(false);
|
||||
expect([offline, fetcher, proxied, framer].map(pluginAllowed)).toEqual([true, true, true, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
it('reads noThirdPartyTraffic from the Lite config.json', () => {
|
||||
expect(applyLiteConfig({}).noThirdPartyTraffic).toBe(false);
|
||||
expect(applyLiteConfig({ noThirdPartyTraffic: true }).noThirdPartyTraffic).toBe(true);
|
||||
expect(applyLiteConfig({ noThirdPartyTraffic: 'yes' }).noThirdPartyTraffic).toBe(false);
|
||||
});
|
||||
|
||||
it('turns the browser switches on once the runtime config is fetched', async () => {
|
||||
vi.resetModules();
|
||||
const mode = await import('@/lib/third-party-mode');
|
||||
const { createConfig } = await import('./fixtures/config');
|
||||
fetchSpy.mockImplementation(async () => Response.json(createConfig({ noThirdPartyTraffic: true })));
|
||||
const { fetchConfig } = await import('@/hooks/use-config');
|
||||
await fetchConfig();
|
||||
expect(mode.noThirdPartyTraffic()).toBe(true);
|
||||
});
|
||||
|
||||
it('parses the env spellings', () => {
|
||||
expect(['true', 'TRUE', '1', 'yes', 'on', ' on '].map(parseNoThirdPartyEnv)).toEqual([true, true, true, true, true, true]);
|
||||
expect(['false', '0', '', 'off', 'nope', undefined].map(parseNoThirdPartyEnv)).toEqual([false, false, false, false, false, false]);
|
||||
expect(noThirdPartyTraffic()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host helpers', () => {
|
||||
it('know the built-in hosts and the placeholders', () => {
|
||||
expect(isBuiltInHost('https://notifications.relay.bulwarkmail.org/')).toBe(true);
|
||||
expect(isBuiltInHost('https://extensions.bulwarkmail.org')).toBe(true);
|
||||
expect(isBuiltInHost('https://extensions.corp.example')).toBe(false);
|
||||
expect(isBuiltInHost('not a url')).toBe(false);
|
||||
expect(['mail.example.com', 'x', 'stalwart.internal:8080', '10.0.0.5', '*.vimeo.com', 'acme.test'].every(isPlaceholderHost)).toBe(true);
|
||||
expect(['github.com', 'icons.duckduckgo.com', 'api.example.io'].some(isPlaceholderHost)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// The server half of the "no third-party traffic" mode (#903): every route
|
||||
// and scheduler that talks to a built-in outside host stops before the
|
||||
// request once the switch is on, and works as before when it is off.
|
||||
|
||||
vi.hoisted(() => { process.env.NEXT_PUBLIC_APP_VERSION = '1.18.0'; });
|
||||
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: vi.fn(async () => ({ serverUrl: 'https://mail.example.com', authHeader: 'Basic x', username: 'u' })),
|
||||
}));
|
||||
vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null }));
|
||||
vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => true) }));
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
|
||||
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),
|
||||
getTheme: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
|
||||
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isNoThirdPartyTrafficOn, noThirdPartyTrafficState } from '@/lib/admin/third-party-mode';
|
||||
|
||||
let dir: string;
|
||||
const fetchMock = vi.fn(async (_url: unknown, _init?: unknown) => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
|
||||
|
||||
function calledHosts(): string[] {
|
||||
return fetchMock.mock.calls.map(([url]) => {
|
||||
try { return new URL(String(url)).hostname; } catch { return String(url); }
|
||||
});
|
||||
}
|
||||
|
||||
/** The switch as an admin sets it: config.json, read again. */
|
||||
async function setMode(on: boolean, policy: Record<string, unknown> = {}) {
|
||||
writeFileSync(path.join(dir, 'config.json'), JSON.stringify(on ? { noThirdPartyTraffic: true } : {}));
|
||||
writeFileSync(path.join(dir, 'policy.json'), JSON.stringify(policy));
|
||||
await configManager.load();
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-3p-'));
|
||||
process.env.ADMIN_CONFIG_DIR = dir;
|
||||
process.env.TELEMETRY_DATA_DIR = path.join(dir, 'telemetry');
|
||||
process.env.VERSION_CHECK_DATA_DIR = path.join(dir, 'version');
|
||||
});
|
||||
afterAll(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
delete process.env.ADMIN_CONFIG_DIR;
|
||||
delete process.env.TELEMETRY_DATA_DIR;
|
||||
delete process.env.VERSION_CHECK_DATA_DIR;
|
||||
});
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
delete process.env.NO_THIRD_PARTY_TRAFFIC;
|
||||
registryPlugins.length = 0;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.NO_THIRD_PARTY_TRAFFIC;
|
||||
});
|
||||
|
||||
describe('the switch', () => {
|
||||
it('is off by default and on from config.json', async () => {
|
||||
await setMode(false);
|
||||
expect(await isNoThirdPartyTrafficOn()).toBe(false);
|
||||
expect(noThirdPartyTrafficState()).toEqual({ on: false, source: 'default' });
|
||||
await setMode(true);
|
||||
expect(await isNoThirdPartyTrafficOn()).toBe(true);
|
||||
expect(noThirdPartyTrafficState()).toEqual({ on: true, source: 'admin' });
|
||||
});
|
||||
|
||||
it('takes an env "on" over a config.json false, and any common spelling of on', async () => {
|
||||
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: false }));
|
||||
await configManager.load();
|
||||
for (const value of ['true', '1', 'YES', 'on']) {
|
||||
process.env.NO_THIRD_PARTY_TRAFFIC = value;
|
||||
expect(await isNoThirdPartyTrafficOn()).toBe(true);
|
||||
expect(noThirdPartyTrafficState()).toEqual({ on: true, source: 'env' });
|
||||
}
|
||||
process.env.NO_THIRD_PARTY_TRAFFIC = 'false';
|
||||
expect(await isNoThirdPartyTrafficOn()).toBe(false);
|
||||
});
|
||||
|
||||
it('is in the client config', async () => {
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
await setMode(true);
|
||||
expect((await (await GET(new NextRequest('https://webmail.example/api/config'))).json()).noThirdPartyTraffic).toBe(true);
|
||||
await setMode(false);
|
||||
expect((await (await GET(new NextRequest('https://webmail.example/api/config'))).json()).noThirdPartyTraffic).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an admin "off" while the environment says on', async () => {
|
||||
const { PATCH } = await import('@/app/api/admin/config/route');
|
||||
process.env.NO_THIRD_PARTY_TRAFFIC = 'true';
|
||||
const res = await PATCH(new NextRequest('https://webmail.example/api/admin/config', {
|
||||
method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: false }),
|
||||
}));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sender favicons', () => {
|
||||
it('answer 403 without contacting DuckDuckGo', async () => {
|
||||
const { GET } = await import('@/app/api/favicon/route');
|
||||
await setMode(true);
|
||||
const res = await GET(new NextRequest('https://webmail.example/api/favicon?domain=github.com'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('come from DuckDuckGo with the mode off', async () => {
|
||||
const { GET } = await import('@/app/api/favicon/route');
|
||||
await setMode(false);
|
||||
await GET(new NextRequest('https://webmail.example/api/favicon?domain=gitlab.com'));
|
||||
expect(calledHosts()).toEqual(['icons.duckduckgo.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translation', () => {
|
||||
const post = async (body: Record<string, unknown>) => {
|
||||
const { POST } = await import('@/app/api/translate/route');
|
||||
return POST(new NextRequest('https://webmail.example/api/translate', { method: 'POST', body: JSON.stringify(body) }));
|
||||
};
|
||||
|
||||
it('refuses MyMemory before any request', async () => {
|
||||
await setMode(true);
|
||||
const res = await post({ text: 'Hallo Welt', target: 'en', source: 'de' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still uses MyMemory with the mode off, and the admin\'s LibreTranslate with it on', async () => {
|
||||
fetchMock.mockImplementation(async () => Response.json({ responseData: { translatedText: 'Hello world' }, translatedText: 'Hello world' }));
|
||||
await setMode(false);
|
||||
await post({ text: 'Hallo Welt', target: 'en', source: 'de' });
|
||||
expect(calledHosts()).toContain('api.mymemory.translated.net');
|
||||
|
||||
fetchMock.mockClear();
|
||||
process.env.LIBRETRANSLATE_URL = 'https://translate.corp.example';
|
||||
try {
|
||||
await setMode(true);
|
||||
await post({ text: 'Hallo Welt', target: 'en', source: 'de', provider: 'libretranslate' });
|
||||
expect(calledHosts()).toEqual(['translate.corp.example']);
|
||||
} finally {
|
||||
delete process.env.LIBRETRANSLATE_URL;
|
||||
fetchMock.mockImplementation(async () => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('connector', () => {
|
||||
it('answers 404 in the mode', async () => {
|
||||
const { GET } = await import('@/app/api/connector/capabilities/route');
|
||||
await setMode(false);
|
||||
expect((await GET()).status).toBe(200);
|
||||
await setMode(true);
|
||||
expect((await GET()).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry', () => {
|
||||
it('is off whatever the saved consent says, and send-now sends nothing', async () => {
|
||||
const { effectiveConsent, saveState, loadState, sendOnce } = await import('@/lib/telemetry');
|
||||
await setMode(false);
|
||||
await saveState({ ...(await loadState()), consent: 'on' });
|
||||
expect((await effectiveConsent()).consent).toBe('on');
|
||||
|
||||
await setMode(true);
|
||||
expect(await effectiveConsent()).toMatchObject({ consent: 'off', source: 'no-third-party' });
|
||||
expect((await sendOnce({ reason: 'test' })).ok).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('version check', () => {
|
||||
it('asks the version endpoint only with the mode off', async () => {
|
||||
const { checkOnce } = await import('@/lib/version-check');
|
||||
fetchMock.mockImplementation(async () => Response.json({ latest: '1.18.0', severity: 'none' }));
|
||||
try {
|
||||
await setMode(true);
|
||||
expect(await checkOnce()).toMatchObject({ ok: false, error: 'off: no third-party traffic' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await setMode(false);
|
||||
await checkOnce();
|
||||
expect(calledHosts()).toEqual(['version.telemetry.bulwarkmail.org']);
|
||||
} finally {
|
||||
fetchMock.mockImplementation(async () => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension directory', () => {
|
||||
it('refuses the official directory in the mode and keeps a self-hosted one', async () => {
|
||||
const { directoryOff } = await import('@/lib/admin/marketplace-directory');
|
||||
const { GET } = await import('@/app/api/admin/marketplace/route');
|
||||
await setMode(true);
|
||||
expect(await directoryOff()).toBe(true);
|
||||
const res = await GET(new NextRequest('https://webmail.example/api/admin/marketplace'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: true, extensionDirectoryUrl: 'https://extensions.corp.example' }));
|
||||
await configManager.load();
|
||||
expect(await directoryOff()).toBe(false);
|
||||
|
||||
await setMode(false);
|
||||
expect(await directoryOff()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the installed list of the updates view', async () => {
|
||||
const { GET } = await import('@/app/api/admin/marketplace/updates/route');
|
||||
await setMode(true);
|
||||
const res = await GET(new NextRequest('https://webmail.example/api/admin/marketplace/updates'));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).error).toMatch(/official extension directory is off/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugins', () => {
|
||||
const plugins = [
|
||||
{ id: 'offline', name: 'Offline', version: '1.0.0', enabled: true, permissions: ['email:read'] },
|
||||
{ id: 'fetcher', name: 'Fetcher', version: '1.0.0', enabled: true, permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] },
|
||||
{ id: 'proxied', name: 'Proxied', version: '1.0.0', enabled: true, permissions: ['http:proxy'] },
|
||||
{ id: 'framer', name: 'Framer', version: '1.0.0', enabled: true, permissions: [], frameOrigins: ['https://www.youtube-nocookie.com'] },
|
||||
];
|
||||
|
||||
it('that declare network access never reach a browser in the mode', async () => {
|
||||
registryPlugins.push(...plugins);
|
||||
const { GET } = await import('@/app/api/plugins/route');
|
||||
await setMode(false);
|
||||
const off = await (await GET(new NextRequest('https://webmail.example/api/plugins'))).json();
|
||||
expect(off.plugins.map((p: { id: string }) => p.id)).toEqual(['offline', 'fetcher', 'proxied', 'framer']);
|
||||
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(['offline']);
|
||||
});
|
||||
|
||||
it('get no outside frames in the CSP', async () => {
|
||||
registryPlugins.push(...plugins);
|
||||
const { getEnabledPluginFrameOrigins, invalidateFrameOriginsCache } = await import('@/lib/admin/csp-frame-origins');
|
||||
await setMode(false);
|
||||
invalidateFrameOriginsCache();
|
||||
expect(await getEnabledPluginFrameOrigins()).toEqual(['https://www.youtube-nocookie.com']);
|
||||
await setMode(true);
|
||||
invalidateFrameOriginsCache();
|
||||
expect(await getEnabledPluginFrameOrigins()).toEqual([]);
|
||||
});
|
||||
|
||||
it('can\'t use the credential proxy', async () => {
|
||||
const { POST } = await import('@/app/api/plugin-proxy/route');
|
||||
await setMode(true, { features: { pluginsEnabled: true } });
|
||||
const res = await POST(new NextRequest('https://webmail.example/api/plugin-proxy?plugin=proxied&credential=main&url=https%3A%2F%2Fapi.vendor.example%2F', { method: 'POST' }));
|
||||
expect(res.status).toBe(403);
|
||||
expect((await res.json()).code).toBe('no-third-party-traffic');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,25 @@ function lastStatus() {
|
||||
return statuses[statuses.length - 1];
|
||||
}
|
||||
|
||||
describe('loadSandboxedPlugin in the no-third-party mode (#903)', () => {
|
||||
it('refuses a plugin that declares network access and runs one that does not', async () => {
|
||||
const { setNoThirdPartyTraffic } = await import('../third-party-mode');
|
||||
mocks.getCode.mockResolvedValue(CODE);
|
||||
setNoThirdPartyTraffic(true);
|
||||
try {
|
||||
await loadSandboxedPlugin(plugin({ permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] }));
|
||||
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'error', error: 'third-party-off' });
|
||||
expect(mocks.createBackgroundInstance).not.toHaveBeenCalled();
|
||||
expect(getActive('managed-plugin')).toBeUndefined();
|
||||
|
||||
await loadSandboxedPlugin(plugin({ permissions: ['email:read'] }));
|
||||
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' });
|
||||
} finally {
|
||||
setNoThirdPartyTraffic(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSandboxedPlugin bundle sourcing', () => {
|
||||
it('runs a managed plugin from IndexedDB when the cached copy verifies', async () => {
|
||||
mocks.getCode.mockResolvedValue(CODE);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { getPluginRegistry } from './plugin-registry';
|
||||
import { sanitizeFrameOrigins } from '../extension-origins';
|
||||
import { isNoThirdPartyTrafficOn } from './third-party-mode';
|
||||
|
||||
export {
|
||||
isValidFrameOrigin,
|
||||
@@ -40,6 +41,12 @@ export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
||||
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
|
||||
|
||||
try {
|
||||
// No outside frames at all while the server stays off outside hosts (#903).
|
||||
if (await isNoThirdPartyTrafficOn()) {
|
||||
cachedOrigins = [];
|
||||
cachedAt = now;
|
||||
return [];
|
||||
}
|
||||
const registry = await getPluginRegistry();
|
||||
// Dev plugins (PLUGIN_DEV_DIR) are served next to the installed ones and
|
||||
// need their embeds allowed too.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import { configManager } from './config-manager';
|
||||
import { getPluginRegistry, getThemeRegistry, type ExtensionSource } from './plugin-registry';
|
||||
import { listHistory, type HistoryEntry } from './extension-history';
|
||||
import { isNoThirdPartyTrafficOn } from './third-party-mode';
|
||||
import { isBuiltInHost } from '@/lib/outgoing-hosts';
|
||||
|
||||
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
|
||||
|
||||
@@ -21,7 +23,24 @@ export type Channel = 'stable' | 'beta';
|
||||
|
||||
export async function getDirectoryUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
const url = configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
// Defense in depth: the routes ask directoryOff() first and answer 403.
|
||||
if (isBuiltInHost(url) && await isNoThirdPartyTrafficOn()) throw new DirectoryError(DIRECTORY_OFF_MESSAGE, 403);
|
||||
return url;
|
||||
}
|
||||
|
||||
export const DIRECTORY_OFF_MESSAGE =
|
||||
'The official extension directory is off: this server does not contact outside services (NO_THIRD_PARTY_TRAFFIC). Point EXTENSION_DIRECTORY_URL at a self-hosted directory to use the marketplace.';
|
||||
|
||||
/**
|
||||
* Whether the marketplace must not ask its directory: the no-third-party
|
||||
* mode is on and the directory is the official one (#903). A self-hosted
|
||||
* directory is the deployment's own and stays reachable.
|
||||
*/
|
||||
export async function directoryOff(): Promise<boolean> {
|
||||
await configManager.ensureLoaded();
|
||||
const url = configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
return isBuiltInHost(url) && await isNoThirdPartyTrafficOn();
|
||||
}
|
||||
|
||||
export function getDirectoryChannel(): Channel {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { configManager } from './config-manager';
|
||||
import { parseNoThirdPartyEnv } from '@/lib/third-party-mode';
|
||||
|
||||
/**
|
||||
* The "no third-party traffic" switch (#903) on the server.
|
||||
*
|
||||
* It is config, not policy: instance-wide, because most of what it stops
|
||||
* (telemetry, the version check, the extension directory) runs for no
|
||||
* particular user. NO_THIRD_PARTY_TRAFFIC in the environment wins over
|
||||
* config.json in one direction only: an env "on" can't be switched off from
|
||||
* the admin panel, so a deployment that pins it stays closed.
|
||||
*/
|
||||
|
||||
export type NoThirdPartySource = 'env' | 'admin' | 'default';
|
||||
|
||||
export function noThirdPartyTrafficState(): { on: boolean; source: NoThirdPartySource } {
|
||||
if (parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) return { on: true, source: 'env' };
|
||||
const entry = configManager.getAllWithSources().noThirdPartyTraffic;
|
||||
const on = entry?.value === true;
|
||||
return { on, source: entry?.source === 'admin' ? 'admin' : on ? 'env' : 'default' };
|
||||
}
|
||||
|
||||
/** Whether every built-in outside host is off. Loads the config first. */
|
||||
export async function isNoThirdPartyTrafficOn(): Promise<boolean> {
|
||||
if (parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) return true;
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<boolean>('noThirdPartyTraffic', false) === true;
|
||||
}
|
||||
|
||||
/** The answer a route gives instead of contacting an outside host. */
|
||||
export function thirdPartyRefusal(what: string): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: `${what} is off: this server doesn't contact outside services (NO_THIRD_PARTY_TRAFFIC).`, code: 'no-third-party-traffic' },
|
||||
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -356,6 +356,13 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
// white-label or self-hosted deployment at its own connector.
|
||||
connectorEnabled: { envVar: 'CONNECTOR_ENABLED', type: 'boolean', defaultValue: true },
|
||||
connectorUrl: { envVar: 'CONNECTOR_URL', type: 'url', defaultValue: 'https://connector.bulwarkmail.org' },
|
||||
// Fail-closed "no third-party traffic" mode (#903): every built-in outside
|
||||
// host in lib/outgoing-hosts.ts is off (telemetry, version check, the
|
||||
// built-in push relay, the official extension directory, the connector,
|
||||
// sender favicons, MyMemory, maps links, WKD) and plugins that declare
|
||||
// network access don't run. Instance-wide on purpose. An env "on" can't be
|
||||
// switched off in the admin panel (lib/admin/third-party-mode.ts).
|
||||
noThirdPartyTraffic: { envVar: 'NO_THIRD_PARTY_TRAFFIC', type: 'boolean', defaultValue: false },
|
||||
// WOPI document editing (#425). `wopiClientUrl` is the editor's base URL
|
||||
// (Collabora Online / OnlyOffice / EuroOffice, ...); discovery is fetched
|
||||
// from `<url>/hosting/discovery` unless the URL already carries a path.
|
||||
|
||||
@@ -64,6 +64,7 @@ const configValidators = {
|
||||
embeddedMode: isBoolean,
|
||||
parentOrigin: isString,
|
||||
sourceCodeUrl: isString,
|
||||
noThirdPartyTraffic: isBoolean,
|
||||
} satisfies Validators<ConfigData>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,8 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin
|
||||
embeddedMode: bool(input.embeddedMode, false),
|
||||
parentOrigin: str(input.parentOrigin, ''),
|
||||
sourceCodeUrl: httpUrlOrEmpty(input.sourceCodeUrl),
|
||||
// Same browser-side switches as the server build (lib/outgoing-hosts.ts).
|
||||
noThirdPartyTraffic: bool(input.noThirdPartyTraffic, false),
|
||||
...LITE_FORCED_FLAGS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Every outside host Bulwark itself knows about, what talks to it, and what
|
||||
* turns it off (#903).
|
||||
*
|
||||
* "Outside" means a host the deployment didn't configure: the admin's JMAP
|
||||
* servers, push relays, OAuth issuer, WOPI editor, sidebar apps and a
|
||||
* self-hosted extension directory are the deployment's own choice and stay
|
||||
* reachable. Everything listed here is off while `noThirdPartyTraffic`
|
||||
* (env NO_THIRD_PARTY_TRAFFIC) is on.
|
||||
*
|
||||
* lib/__tests__/outgoing-hosts.test.ts scans the source for http(s) host
|
||||
* literals and fails on any that is neither listed here nor in
|
||||
* NON_REQUEST_HOSTS, so a new built-in host can't slip in unnoticed; a
|
||||
* second test turns the mode on and checks every entry's switch.
|
||||
*
|
||||
* Plain data and pure functions: imported by the browser, the server routes,
|
||||
* the admin page and the tests.
|
||||
*/
|
||||
|
||||
import { noThirdPartyTraffic } from './third-party-mode';
|
||||
|
||||
export type OutgoingSide = 'server' | 'browser';
|
||||
|
||||
export interface OutgoingHost {
|
||||
/** Stable id, used by the tests and the admin list. */
|
||||
id: string;
|
||||
/** The host as people should read it. */
|
||||
host: string;
|
||||
/** How the host appears in the source after `https://`, when it differs from `host`. */
|
||||
literals?: string[];
|
||||
/** Who opens the connection: this server, or the user's browser. */
|
||||
side: OutgoingSide;
|
||||
/** What uses it, in one sentence. */
|
||||
usedBy: string;
|
||||
/** How to turn it off without the mode. */
|
||||
off: string;
|
||||
/** What the mode does instead. */
|
||||
inMode: string;
|
||||
/** Also present in Bulwark Lite. */
|
||||
lite: boolean;
|
||||
}
|
||||
|
||||
export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
|
||||
{
|
||||
id: 'telemetry',
|
||||
host: 'telemetry.bulwarkmail.org',
|
||||
side: 'server',
|
||||
usedBy: 'Anonymous daily heartbeat, sent only after an admin turned telemetry on.',
|
||||
off: 'Off by default. BULWARK_TELEMETRY=off, or Admin > Telemetry.',
|
||||
inMode: 'Never sent, whatever the telemetry setting says.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'version-check',
|
||||
host: 'version.telemetry.bulwarkmail.org',
|
||||
side: 'server',
|
||||
usedBy: 'Hourly check for a newer Bulwark release, shown to admins.',
|
||||
off: 'BULWARK_UPDATE_CHECK=off, or BULWARK_UPDATE_CHECK_URL= (empty).',
|
||||
inMode: 'Not checked; Admin > Version says why.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'push-relay',
|
||||
host: 'notifications.relay.bulwarkmail.org',
|
||||
side: 'browser',
|
||||
usedBy: 'Built-in Web Push relay that wakes the browser for new mail.',
|
||||
off: 'Add your own relays under Admin > User policy > Push relays and lock the choice.',
|
||||
inMode: 'Not offered. Push works only with a relay the admin configured.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'extension-directory',
|
||||
host: 'extensions.bulwarkmail.org',
|
||||
side: 'server',
|
||||
usedBy: 'Marketplace browsing, installs and update checks in the admin panel.',
|
||||
off: 'EXTENSION_DIRECTORY_URL pointing at a self-hosted directory.',
|
||||
inMode: 'Off, unless extensionDirectoryUrl points at another host.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'connector',
|
||||
host: 'connector.bulwarkmail.org',
|
||||
side: 'server',
|
||||
usedBy: 'Links in the docs and the directory that open this instance; the connector asks /api/connector/capabilities.',
|
||||
off: 'CONNECTOR_ENABLED=false.',
|
||||
inMode: '/api/connector/capabilities answers 404.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'favicons',
|
||||
host: 'icons.duckduckgo.com',
|
||||
side: 'server',
|
||||
usedBy: 'Sender icons next to messages, fetched by /api/favicon.',
|
||||
off: 'Users can turn sender icons off in Settings > Message list.',
|
||||
inMode: 'Avatars show initials; /api/favicon answers 403 before any request.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'translate',
|
||||
host: 'api.mymemory.translated.net',
|
||||
side: 'server',
|
||||
usedBy: 'Default provider of /api/translate, which the Translate plugin calls.',
|
||||
off: 'Set LIBRETRANSLATE_URL to your own LibreTranslate and pick it in the plugin.',
|
||||
inMode: 'Refused with 403; LibreTranslate at LIBRETRANSLATE_URL still works.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'maps',
|
||||
host: 'www.google.com',
|
||||
side: 'browser',
|
||||
usedBy: 'Tapping the address of a calendar event opens Google Maps.',
|
||||
off: 'Nothing else turns it off; it is a link the user taps.',
|
||||
inMode: 'The address is plain text with a copy button.',
|
||||
lite: true,
|
||||
},
|
||||
{
|
||||
id: 'wkd',
|
||||
host: 'openpgpkey.<recipient domain>',
|
||||
literals: ['openpgpkey.'],
|
||||
side: 'browser',
|
||||
usedBy: 'OpenPGP key lookup (WKD) at the recipient\'s domain, for PGP plugins.',
|
||||
off: 'Don\'t install a PGP plugin that looks keys up.',
|
||||
inMode: 'The lookup is refused.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-avatars',
|
||||
host: 'randomuser.me',
|
||||
literals: ['randomuser.me', 'img.freepik.com'],
|
||||
side: 'browser',
|
||||
usedBy: 'Sample sender photos, only with the built-in mock server (DEV_MOCK_JMAP=true).',
|
||||
off: 'Don\'t run the mock server.',
|
||||
inMode: 'Not shown.',
|
||||
lite: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Hosts in the source that Bulwark never connects to on its own: docs links,
|
||||
* XML namespaces, credits, examples in comments and sample text. Each is
|
||||
* allowed only in the files named, so the same host turning up somewhere new
|
||||
* has to be looked at again.
|
||||
*/
|
||||
export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[]; why: string }[] = [
|
||||
{ host: 'bulwarkmail.org', why: 'docs links and comments', files: ['app/(main)/admin/_pages/telemetry.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts'] },
|
||||
{ host: 'bulwarkmail.com', why: 'JMAP capability URI', files: ['lib/jmap/client.ts'] },
|
||||
{ host: 'extensions.bulwarkmail.org', why: 'signed statement type URIs', files: ['lib/admin/directory-keys.ts'] },
|
||||
{ host: 'www.w3.org', why: 'XML namespaces', files: ['app/(main)/setup/page.tsx', 'components/ui/flag-icons.tsx', 'lib/favicon-badge.ts'] },
|
||||
{ host: 'creativecommons.org', why: 'licence metadata inside an SVG', files: ['components/ui/flag-icons.tsx'] },
|
||||
{ host: 'github.com', why: 'source code link (AGPL) and SVG metadata', files: ['components/settings/about-data-settings.tsx', 'components/ui/flag-icons.tsx'] },
|
||||
{ host: 'www.rfc-editor.org', why: 'comments', files: ['lib/jmap/client.ts'] },
|
||||
{ host: 'devguide.calconnect.org', why: 'comment', files: ['lib/jmap/client.ts'] },
|
||||
{ host: 'stalw.art', why: 'comment', files: ['app/api/auth/impersonate/route.ts'] },
|
||||
{ host: 'tabler.io', why: 'icon credit link', files: ['app/(main)/admin/_pages/policy.tsx'] },
|
||||
{ host: 'www.youtube-nocookie.com', why: 'example in a comment', files: ['lib/plugin-types.ts'] },
|
||||
{ host: 'bulwark-webmail.vercel.app', why: 'sample message text', files: ['stores/email-store.ts'] },
|
||||
];
|
||||
|
||||
/** Folders whose host literals are sample mail content, never requests. */
|
||||
export const NON_REQUEST_PATHS: readonly string[] = [
|
||||
'lib/demo/fixtures/',
|
||||
'app/api/dev-jmap/',
|
||||
];
|
||||
|
||||
const RESERVED_TLDS = new Set(['example', 'test', 'invalid', 'localhost', 'local', 'internal', 'lan']);
|
||||
|
||||
/**
|
||||
* Hosts that can't be anyone else's: example and reserved names, single
|
||||
* labels, IP literals and CSP wildcards. Placeholders and tests use them.
|
||||
*/
|
||||
export function isPlaceholderHost(raw: string): boolean {
|
||||
const host = raw.toLowerCase().replace(/:\d+$/, '').replace(/\.+$/, '');
|
||||
if (!host.includes('.') || host.includes('*')) return true;
|
||||
if (/^\d{1,3}(\.\d{1,3}){0,3}\.?$/.test(host)) return true;
|
||||
const labels = host.split('.');
|
||||
const tld = labels[labels.length - 1];
|
||||
if (RESERVED_TLDS.has(tld)) return true;
|
||||
return /(^|\.)example\.(com|org|net)$/.test(host);
|
||||
}
|
||||
|
||||
/** Every host literal the registry accounts for. */
|
||||
export function registeredLiterals(): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const entry of OUTGOING_HOSTS) for (const literal of entry.literals ?? [entry.host]) out.add(literal);
|
||||
return out;
|
||||
}
|
||||
|
||||
function hostnameOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a URL points at one of the built-in outside hosts above. */
|
||||
export function isBuiltInHost(url: string): boolean {
|
||||
const host = hostnameOf(url);
|
||||
if (!host) return false;
|
||||
return OUTGOING_HOSTS.some((entry) => (entry.literals ?? [entry.host]).some((literal) => literal === host));
|
||||
}
|
||||
|
||||
// ─── Browser-side switches ────────────────────────────────────────────────
|
||||
|
||||
/** A push relay may be used: always, except the built-in one in the mode. */
|
||||
export function relayAllowed(url: string): boolean {
|
||||
return !(noThirdPartyTraffic() && isBuiltInHost(url));
|
||||
}
|
||||
|
||||
/** Sender icons through /api/favicon. */
|
||||
export function senderFaviconsAllowed(): boolean {
|
||||
return !noThirdPartyTraffic();
|
||||
}
|
||||
|
||||
/** The Google Maps link on an event's address. */
|
||||
export function mapsLinksAllowed(): boolean {
|
||||
return !noThirdPartyTraffic();
|
||||
}
|
||||
|
||||
/** OpenPGP WKD lookups at the recipient's domain. */
|
||||
export function wkdAllowed(): boolean {
|
||||
return !noThirdPartyTraffic();
|
||||
}
|
||||
|
||||
/** Sample photos of the mock server. */
|
||||
export function sampleAvatarsAllowed(): boolean {
|
||||
return !noThirdPartyTraffic();
|
||||
}
|
||||
|
||||
export interface NetworkDeclaringPlugin {
|
||||
permissions?: readonly string[];
|
||||
httpOrigins?: readonly string[];
|
||||
frameOrigins?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin declares network access when it may fetch outside hosts
|
||||
* (`http:fetch` with httpOrigins), use the credential proxy (`http:proxy`) or
|
||||
* embed outside frames (frameOrigins). Same-origin `http:post` doesn't count.
|
||||
*/
|
||||
export function pluginDeclaresNetwork(plugin: NetworkDeclaringPlugin): boolean {
|
||||
const permissions = plugin.permissions ?? [];
|
||||
return permissions.includes('http:fetch')
|
||||
|| permissions.includes('http:proxy')
|
||||
|| (plugin.httpOrigins?.length ?? 0) > 0
|
||||
|| (plugin.frameOrigins?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Error code a refused plugin carries (shown translated in Settings > Plugins). */
|
||||
export const PLUGIN_REFUSED_ERROR = 'third-party-off';
|
||||
|
||||
/** Whether a plugin may run in this browser. */
|
||||
export function pluginAllowed(plugin: NetworkDeclaringPlugin): boolean {
|
||||
return !(noThirdPartyTraffic() && pluginDeclaresNetwork(plugin));
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import { doNotificationsShow } from './host-api-notify';
|
||||
import { setSectionTitle } from './section-titles';
|
||||
import { requestPanel } from './panel-requests';
|
||||
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
|
||||
import { pluginAllowed, wkdAllowed } from '../outgoing-hosts';
|
||||
|
||||
/**
|
||||
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||
@@ -667,6 +668,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');
|
||||
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://'); }
|
||||
@@ -1169,6 +1173,8 @@ export interface WkdResult {
|
||||
}
|
||||
|
||||
export async function doGetPublicKeyFromWKD(email: string): Promise<WkdResult> {
|
||||
// The lookup goes to the recipient's domain: off with third-party traffic (#903).
|
||||
if (!wkdAllowed()) return { status: 'ERROR', error: 'Key lookups at other domains are off on this server' };
|
||||
const cleanEmail = email.trim();
|
||||
const atIndex = cleanEmail.lastIndexOf('@');
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { clearSectionTitle } from './section-titles';
|
||||
import { cancelPluginProxyRequests } from './proxy-client';
|
||||
import { cancelConnectFor } from './connect-dialog';
|
||||
import type { SlotName } from '../plugin-types';
|
||||
import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts';
|
||||
|
||||
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
||||
|
||||
@@ -222,6 +223,13 @@ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
}
|
||||
|
||||
async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
// A plugin that declares network access doesn't start while third-party
|
||||
// traffic is off; the rest run (#903).
|
||||
if (!pluginAllowed(plugin)) {
|
||||
storeAccessor?.setPluginStatus(plugin.id, 'error', PLUGIN_REFUSED_ERROR);
|
||||
console.warn(`[plugin-sandbox] "${plugin.id}" not started: it declares network access and third-party traffic is off`);
|
||||
return;
|
||||
}
|
||||
|
||||
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
||||
try {
|
||||
|
||||
+14
-4
@@ -3,6 +3,7 @@
|
||||
// registration (which carries their address label) at an arbitrary host.
|
||||
|
||||
import type { PushRelayOption, SettingsPolicy } from '@/lib/admin/types';
|
||||
import { relayAllowed } from '@/lib/outgoing-hosts';
|
||||
|
||||
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
|
||||
// The built-in entry can be repointed at build time via NEXT_PUBLIC_PUSH_RELAY_URL.
|
||||
@@ -34,7 +35,9 @@ export function relayDisplayLabel(option: PushRelayOption): string {
|
||||
|
||||
/**
|
||||
* The relays a user may choose between: the built-in default first, then the
|
||||
* admin-configured list. Invalid and duplicate URLs are dropped.
|
||||
* admin-configured list. Invalid and duplicate URLs are dropped, and so is
|
||||
* the built-in relay while third-party traffic is off (#903): push then
|
||||
* works only with a relay the admin runs.
|
||||
*/
|
||||
export function resolvePushRelayOptions(policy: PushRelayPolicy | undefined | null): PushRelayOption[] {
|
||||
const options: PushRelayOption[] = [];
|
||||
@@ -42,7 +45,7 @@ export function resolvePushRelayOptions(policy: PushRelayPolicy | undefined | nu
|
||||
|
||||
const add = (label: string | undefined, rawUrl: string | undefined) => {
|
||||
const url = normalizeRelayUrl(rawUrl);
|
||||
if (!isValidRelayUrl(url) || seen.has(url)) return;
|
||||
if (!isValidRelayUrl(url) || seen.has(url) || !relayAllowed(url)) return;
|
||||
seen.add(url);
|
||||
options.push({ label: relayDisplayLabel({ label: label ?? '', url }), url });
|
||||
};
|
||||
@@ -56,10 +59,17 @@ export function resolvePushRelayOptions(policy: PushRelayPolicy | undefined | nu
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The relay used when the user has not picked one (or their pick is gone). */
|
||||
/**
|
||||
* The relay used when the user has not picked one (or their pick is gone).
|
||||
* Empty when none may be used: third-party traffic is off and the admin
|
||||
* configured no relay of their own.
|
||||
*/
|
||||
export function resolveDefaultRelayUrl(policy: PushRelayPolicy | undefined | null): string {
|
||||
const configured = normalizeRelayUrl(policy?.pushRelayUrl);
|
||||
return isValidRelayUrl(configured) ? configured : normalizeRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
if (isValidRelayUrl(configured) && relayAllowed(configured)) return configured;
|
||||
const builtIn = normalizeRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
if (relayAllowed(builtIn)) return builtIn;
|
||||
return resolvePushRelayOptions(policy)[0]?.url ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ export async function sendOnce(opts?: { reason?: string }): Promise<{
|
||||
status?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
// effectiveConsent() is off in the no-third-party mode, so this also stops
|
||||
// a manual "send now" before any request (#903).
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
|
||||
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { TelemetryStateFile, ConsentState } from './types';
|
||||
import { DEFAULT_ENDPOINT } from './types';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
|
||||
function getDir(): string {
|
||||
return process.env.TELEMETRY_DATA_DIR ||
|
||||
@@ -83,15 +84,17 @@ export async function saveState(state: TelemetryStateFile): Promise<void> {
|
||||
await rename(tmp, statePath());
|
||||
}
|
||||
|
||||
// Effective consent: env var wins over file. UI changes are blocked
|
||||
// when env override is active so the user knows where it's coming from.
|
||||
// Effective consent: the no-third-party mode wins over everything (#903),
|
||||
// then the env var, then the file. UI changes are blocked when either
|
||||
// override is active so the user knows where it's coming from.
|
||||
export async function effectiveConsent(): Promise<{
|
||||
consent: ConsentState;
|
||||
source: 'env' | 'file';
|
||||
source: 'no-third-party' | 'env' | 'file';
|
||||
state: TelemetryStateFile;
|
||||
}> {
|
||||
const envState = envOverride();
|
||||
const state = await loadState();
|
||||
if (await isNoThirdPartyTrafficOn()) return { consent: 'off', source: 'no-third-party', state };
|
||||
if (envState) return { consent: envState, source: 'env', state };
|
||||
return { consent: state.consent, source: 'file', state };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The "no third-party traffic" mode (#903) as the browser sees it.
|
||||
*
|
||||
* The server reads the switch from config (lib/admin/third-party-mode.ts) and
|
||||
* hands it to the client through /api/config; Lite reads it from its
|
||||
* config.json. fetchConfig() stores it here, and every browser-side switch in
|
||||
* lib/outgoing-hosts.ts asks this module, so a component never has to thread
|
||||
* the flag through props to stay off an outside host.
|
||||
*/
|
||||
|
||||
let enabled = false;
|
||||
|
||||
/** NO_THIRD_PARTY_TRAFFIC: true, 1, yes or on (any case) turn it on; anything else is off. */
|
||||
export function parseNoThirdPartyEnv(value: string | undefined | null): boolean {
|
||||
return /^(true|1|yes|on)$/i.test((value ?? '').trim());
|
||||
}
|
||||
|
||||
/** Called once the runtime config is known. */
|
||||
export function setNoThirdPartyTraffic(on: boolean): void {
|
||||
enabled = on;
|
||||
}
|
||||
|
||||
/** Whether the browser must stay off every built-in outside host. */
|
||||
export function noThirdPartyTraffic(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from './state';
|
||||
import { fetchStatus } from './fetcher';
|
||||
import type { UpdateStatus } from './types';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing
|
||||
@@ -12,6 +13,9 @@ const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15
|
||||
|
||||
let currentTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** What checkOnce reports while NO_THIRD_PARTY_TRAFFIC is on. */
|
||||
export const NO_THIRD_PARTY_ERROR = 'off: no third-party traffic';
|
||||
|
||||
function jitteredDelay(base: number): number {
|
||||
const j = (Math.random() * 2 - 1) * JITTER_MS;
|
||||
return Math.max(60_000, base + j);
|
||||
@@ -23,6 +27,7 @@ export async function checkOnce(opts?: { reason?: string }): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
if (disabledByEnv()) return { ok: false, error: 'disabled by env' };
|
||||
if (await isNoThirdPartyTrafficOn()) return { ok: false, error: NO_THIRD_PARTY_ERROR };
|
||||
|
||||
const state = await loadState();
|
||||
const endpoint = effectiveEndpoint(state);
|
||||
@@ -75,6 +80,10 @@ export async function startScheduler(): Promise<void> {
|
||||
logger.info('version-check: scheduler not started (disabled by env)');
|
||||
return;
|
||||
}
|
||||
if (await isNoThirdPartyTrafficOn()) {
|
||||
logger.info('version-check: scheduler not started (no third-party traffic)');
|
||||
return;
|
||||
}
|
||||
const state = await loadState();
|
||||
if (!effectiveEndpoint(state)) {
|
||||
logger.info('version-check: scheduler not started (no endpoint)');
|
||||
|
||||
+14
-2
@@ -8,6 +8,8 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
|
||||
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { relayAllowed } from '@/lib/outgoing-hosts';
|
||||
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import {
|
||||
EMPTY_NOTIFICATION_MUTES,
|
||||
@@ -433,7 +435,9 @@ export function isWebPushSupported(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Every relay request is built here, so the built-in relay can't be reached in the no-third-party mode (#903). */
|
||||
function buildRelayUrl(base: string, suffix: string): string {
|
||||
if (!relayAllowed(base)) throw new Error('The built-in push relay is off: this server does not contact outside services');
|
||||
return base.replace(/\/+$/, '') + suffix;
|
||||
}
|
||||
|
||||
@@ -893,7 +897,7 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
||||
// Enabling again starts from the full emailPush map.
|
||||
localStorage.removeItem(emailPushRefusedKey(accountId));
|
||||
|
||||
if (deviceClientId && relayBaseUrl) {
|
||||
if (deviceClientId && relayBaseUrl && relayAllowed(relayBaseUrl)) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
@@ -980,7 +984,7 @@ export async function revokePushDevice(params: {
|
||||
}
|
||||
|
||||
await params.client.destroyPushSubscription(params.device.id);
|
||||
if (relayBaseUrl && params.device.deviceClientId) {
|
||||
if (relayBaseUrl && relayAllowed(relayBaseUrl) && params.device.deviceClientId) {
|
||||
await fetch(
|
||||
buildRelayUrl(
|
||||
relayBaseUrl,
|
||||
@@ -1071,6 +1075,14 @@ async function runResync(accountId: string, params: ResyncWebPushParams): Promis
|
||||
// isWebPushEnabled (it requires a live browser subscription) would do.
|
||||
if (!isWebPushSupported() || Notification.permission !== 'granted'
|
||||
|| !localStorage.getItem(subscriptionIdKey(accountId))) return false;
|
||||
// Registered with the built-in relay before third-party traffic was
|
||||
// turned off, and no relay of the organisation's own to move to: drop the
|
||||
// server's PushSubscription so the mail server stops pinging that relay
|
||||
// (#903). disableWebPush skips the relay itself.
|
||||
if (!params.relayBaseUrl && noThirdPartyTraffic()) {
|
||||
await disableWebPush({ client: params.client });
|
||||
return false;
|
||||
}
|
||||
if (!params.force && !(await resyncDue(accountId))) return false;
|
||||
await enableWebPush({
|
||||
client: params.client,
|
||||
|
||||
@@ -981,6 +981,12 @@
|
||||
"rule_folder_failed": "Der Ordner konnte nicht erstellt werden",
|
||||
"vacation_filters_save_failed": "Abwesenheits-Einstellungen gespeichert, Weiterleitung und Antwortempfänger konnten aber nicht gespeichert werden"
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push-Benachrichtigungen brauchen ein Push-Relay Ihrer Organisation, und es ist keines eingerichtet. Neue E-Mails werden weiterhin angezeigt, solange die App geöffnet ist.",
|
||||
"relay_desc": "Es werden nur die eigenen Relays Ihrer Organisation angeboten, weil dieser Server keine externen Dienste kontaktiert.",
|
||||
"relay_none": "Keines eingerichtet",
|
||||
"plugin_refused": "Nicht gestartet: Dieses Plugin verbindet sich mit externen Diensten, die Ihre Organisation abgeschaltet hat."
|
||||
},
|
||||
"date": {
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
|
||||
@@ -981,6 +981,12 @@
|
||||
"rule_folder_failed": "Could not create the folder",
|
||||
"vacation_filters_save_failed": "Out of office settings saved, but the forwarding and reply recipients could not be saved"
|
||||
},
|
||||
"third_party": {
|
||||
"push_needs_relay": "Push notifications need a push relay run by your organisation, and none is set up. New mail still shows while the app is open.",
|
||||
"relay_desc": "Only your organisation's own relays are offered, because this server doesn't contact outside services.",
|
||||
"relay_none": "None set up",
|
||||
"plugin_refused": "Not started: this plugin connects to outside services, which your organisation has turned off."
|
||||
},
|
||||
"date": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//
|
||||
// Inputs (env): LITE_TARGET (static | stalwart), LITE_JMAP_SERVER_URL,
|
||||
// LITE_APP_NAME, LITE_ALLOW_CUSTOM_ENDPOINT, LITE_REMEMBER_ME, LITE_DEMO_MODE,
|
||||
// LITE_NO_THIRD_PARTY_TRAFFIC,
|
||||
// LITE_LOCALES, NEXT_PUBLIC_BASE_PATH (static target only),
|
||||
// NEXT_PUBLIC_DEFAULT_LOCALE, GIT_COMMIT. Flags: --target=stalwart,
|
||||
// --skip-prepare (tree already pruned), --dry-run (print the plan only).
|
||||
|
||||
@@ -168,6 +168,8 @@ export function buildLiteConfig(env = {}, { target = "static" } = {}) {
|
||||
: {}),
|
||||
rememberMeEnabled: parseBool(env.LITE_REMEMBER_ME, true),
|
||||
demoMode: parseBool(env.LITE_DEMO_MODE, false),
|
||||
// Written out so deployers find the switch (#903; lib/outgoing-hosts.ts).
|
||||
noThirdPartyTraffic: parseBool(env.LITE_NO_THIRD_PARTY_TRAFFIC, false),
|
||||
loginShowTotp: true,
|
||||
loginShowVersion: true,
|
||||
};
|
||||
@@ -661,6 +663,8 @@ ${demoMode ? "Demo mode is ON: the login page offers a built-in demo account and
|
||||
while \`jmapServerUrl\` is empty.
|
||||
- \`rememberMeEnabled\`: \`false\` never remembers a sign-in (sessions then end with the tab).
|
||||
Sign-ins are otherwise remembered; \`loginShowRememberMe: true\` lets people choose.
|
||||
- \`noThirdPartyTraffic\`: \`true\` keeps the browser off every built-in outside host
|
||||
(no Google Maps links on event addresses; see the third-party traffic docs).
|
||||
Optional keys: \`demoMode\`, \`jmapServers\`, \`jmapServerAutoPickByDomain\`, the login logo/company/link keys and \`loginShow*\` toggles (same names as the Docker env vars, camelCased).
|
||||
3. Allow the browser to talk to the mail server (CORS). In Stalwart, turn on
|
||||
the \`usePermissiveCors\` setting of the \`Http\` object: in the web admin
|
||||
|
||||
Reference in New Issue
Block a user