mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'feat/org-thirdparty' into feat/org-features
This commit is contained in:
@@ -81,6 +81,12 @@ jobs:
|
||||
LITE_DEMO_MODE: ${{ inputs.demo }}
|
||||
run: npm run build:lite
|
||||
|
||||
# Every host literal in the bundle must be a placeholder, the configured
|
||||
# JMAP server, a built-in host the no-third-party switch turns off, or a
|
||||
# string listed as never requested (#903).
|
||||
- name: Scan the bundle for outside hosts
|
||||
run: node scripts/lite/scan-hosts.mjs out
|
||||
|
||||
- name: Package zip
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isConfigReadOnly } from '@/lib/admin/paths';
|
||||
import { getPluginRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { noThirdPartyTrafficState } from '@/lib/admin/third-party-mode';
|
||||
import { listReachableHosts } from '@/lib/admin/reachable-hosts';
|
||||
import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
|
||||
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/outgoing-hosts - the "no third-party traffic" switch with
|
||||
* where its value comes from, the built-in outside hosts it turns off, the
|
||||
* admin-configured hosts that stay reachable, and the installed plugins it
|
||||
* refuses (#903). Organisation > Security renders this; the switch itself is
|
||||
* saved through PATCH /api/admin/config like every other config key.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const state = noThirdPartyTrafficState();
|
||||
const policy = configManager.getPolicy();
|
||||
const registry = await getPluginRegistry().catch(() => ({ plugins: [] }));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
noThirdPartyTraffic: state.on,
|
||||
source: state.source,
|
||||
// The panel can't turn it off when the environment pins it on, and
|
||||
// can't change it at all on a read-only config.
|
||||
readOnly: isConfigReadOnly(),
|
||||
envLocked: state.source === 'env',
|
||||
builtIn: OUTGOING_HOSTS,
|
||||
reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env }),
|
||||
networkPlugins: registry.plugins
|
||||
.filter((plugin) => plugin.enabled && pluginDeclaresNetwork(plugin))
|
||||
.map((plugin) => ({ id: plugin.id, name: plugin.name })),
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Outgoing hosts read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createDemoFileNodes } from '@/lib/demo/fixtures/files';
|
||||
import { generateDemoId } from '@/lib/demo/demo-utils';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import JSZip from 'jszip';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
|
||||
/**
|
||||
* Mock JMAP server for local development.
|
||||
@@ -2035,9 +2036,14 @@ function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodRes
|
||||
return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId];
|
||||
}
|
||||
|
||||
// The sample photos come from randomuser.me: none while third-party traffic
|
||||
// is off (#903). Set per request in POST, which can await the config.
|
||||
let samplePhotos = true;
|
||||
|
||||
function handleContactCardGet(args: MethodArgs, callId: string): MethodResult {
|
||||
const ids = args.ids as string[] | undefined;
|
||||
const list = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
|
||||
const found = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
|
||||
const list = samplePhotos ? found : found.map(({ media: _media, ...rest }) => rest);
|
||||
return ['ContactCard/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
@@ -2826,6 +2832,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
// JMAP API endpoint
|
||||
if (joined === 'api') {
|
||||
samplePhotos = !(await isNoThirdPartyTrafficOn());
|
||||
try {
|
||||
const body = await request.json();
|
||||
const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: async () => (signedIn ? { ...claimed, serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) },
|
||||
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled } }) },
|
||||
}));
|
||||
vi.mock('@/lib/security/same-origin', async () => {
|
||||
const { NextResponse } = await import('next/server');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { listReachableHosts } from '@/lib/admin/reachable-hosts';
|
||||
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import type { OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
|
||||
|
||||
const apiFetch = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/browser-navigation', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/browser-navigation')>()),
|
||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||
}));
|
||||
|
||||
const { ThirdPartySection } = await import('@/components/admin/org/sections/third-party-section');
|
||||
|
||||
const setMessage = vi.fn();
|
||||
const draft = { setMessage } as unknown as OrgPolicyDraft;
|
||||
|
||||
function state(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
noThirdPartyTraffic: false,
|
||||
source: 'default',
|
||||
readOnly: false,
|
||||
envLocked: false,
|
||||
builtIn: OUTGOING_HOSTS,
|
||||
reachable: [{ kind: 'JMAP server', host: 'mail.corp.example', setIn: 'JMAP_SERVER_URL', side: 'both' }],
|
||||
networkPlugins: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function switchOf() {
|
||||
return within(screen.getByTestId('third-party-switch')).getByRole('switch');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
setMessage.mockReset();
|
||||
});
|
||||
|
||||
describe('ThirdPartySection', () => {
|
||||
it('saves the switch as config through /api/admin/config', async () => {
|
||||
let current = state();
|
||||
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/config' && init?.method === 'PATCH') {
|
||||
current = state({ noThirdPartyTraffic: true, source: 'admin' });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
return Response.json(current);
|
||||
});
|
||||
render(<ThirdPartySection draft={draft} />);
|
||||
await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'false'));
|
||||
expect(screen.getByTestId('third-party-scope-note').textContent).toMatch(/whole server, not per domain or group/);
|
||||
expect(screen.getByTestId('third-party-reachable').textContent).toContain('mail.corp.example');
|
||||
|
||||
await act(async () => { fireEvent.click(switchOf()); });
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/admin/config', expect.objectContaining({
|
||||
method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: true }),
|
||||
}));
|
||||
await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'true'));
|
||||
expect(screen.getByText('config.json')).toBeInTheDocument();
|
||||
// No relay of their own: push needs one now.
|
||||
expect(screen.getByText(/Push notifications now need a relay you run yourself/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is read-only when the environment sets it', async () => {
|
||||
apiFetch.mockImplementation(async () => Response.json(state({ noThirdPartyTraffic: true, source: 'env', envLocked: true })));
|
||||
render(<ThirdPartySection draft={draft} />);
|
||||
await waitFor(() => expect(switchOf()).toBeDisabled());
|
||||
expect(screen.getByText('NO_THIRD_PARTY_TRAFFIC')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Set by NO_THIRD_PARTY_TRAFFIC in the environment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is read-only on a read-only config', async () => {
|
||||
apiFetch.mockImplementation(async () => Response.json(state({ readOnly: true })));
|
||||
render(<ThirdPartySection draft={draft} />);
|
||||
await waitFor(() => expect(switchOf()).toBeDisabled());
|
||||
expect(screen.getByText(/The configuration is read-only/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listReachableHosts', () => {
|
||||
it('lists the hosts the deployment configured and leaves the built-in ones out', () => {
|
||||
const config: Record<string, unknown> = {
|
||||
jmapServerUrl: 'https://mail.corp.example',
|
||||
jmapServers: [{ id: 'b', label: 'Branch', url: 'https://jmap.branch.example', oauth: { issuerUrl: 'https://sso.corp.example' } }],
|
||||
oauthEnabled: false,
|
||||
oauthIssuerUrl: 'https://ignored.example',
|
||||
extensionDirectoryUrl: 'https://extensions.bulwarkmail.org',
|
||||
wopiClientUrl: 'https://office.corp.example',
|
||||
loginLogoLightUrl: '/branding/Bulwark_Logo_Color.svg',
|
||||
appLogoLightUrl: 'https://cdn.corp.example/logo.svg',
|
||||
};
|
||||
const hosts = listReachableHosts({
|
||||
get: (key) => config[key],
|
||||
policy: {
|
||||
pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }, { label: 'Built-in', url: 'https://notifications.relay.bulwarkmail.org' }],
|
||||
pushRelayUrl: '',
|
||||
defaultSidebarApps: [{ id: 'w', name: 'Wiki', url: 'https://wiki.corp.example', icon: 'Globe', openMode: 'inline', showOnMobile: true }],
|
||||
},
|
||||
env: { LIBRETRANSLATE_URL: 'https://translate.corp.example' },
|
||||
});
|
||||
expect(hosts.map((h) => `${h.kind}: ${h.host}`)).toEqual([
|
||||
'JMAP server: mail.corp.example',
|
||||
'JMAP server: jmap.branch.example',
|
||||
'OAuth issuer: sso.corp.example',
|
||||
'Push relay: push.corp.example',
|
||||
'Sidebar app: wiki.corp.example',
|
||||
'Document editor (WOPI): office.corp.example',
|
||||
'Translation (LibreTranslate): translate.corp.example',
|
||||
'Branding image: cdn.corp.example',
|
||||
'Sidebar apps users add: any address your users enter',
|
||||
'Calendar subscriptions: any address your users enter',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the relay browsers were built with, flags a different one in the environment, and lists what users enter', () => {
|
||||
const hosts = listReachableHosts({
|
||||
get: (key) => ({ allowCustomJmapEndpoint: true } as Record<string, unknown>)[key],
|
||||
policy: { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { calendarEnabled: false, sidebarAppsEnabled: false } },
|
||||
env: { NEXT_PUBLIC_PUSH_RELAY_URL: 'https://push.runtime.example' },
|
||||
buildRelayUrl: 'https://push.build.example',
|
||||
});
|
||||
expect(hosts.map((h) => [h.host, h.setIn, !!h.note])).toEqual([
|
||||
['push.build.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', false],
|
||||
['push.runtime.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', true],
|
||||
['any address your users enter', 'ALLOW_CUSTOM_JMAP_ENDPOINT', true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2 } from '@/components/icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { ConfigNote, RevertButton, SourceBadge } from '@/components/admin/config-items';
|
||||
import { AdminLink } from '@/components/admin/admin-link';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { OutgoingHost } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import type { ReachableHost } from '@/lib/admin/reachable-hosts';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
/** Organisation feature 5; filled in by its own step (local-data/org-features-plan.md). */
|
||||
interface OutgoingHostsState {
|
||||
noThirdPartyTraffic: boolean;
|
||||
source: 'env' | 'admin' | 'default';
|
||||
readOnly: boolean;
|
||||
envLocked: boolean;
|
||||
builtIn: OutgoingHost[];
|
||||
reachable: ReachableHost[];
|
||||
networkPlugins: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const SIDE_LABEL: Record<ReachableHost['side'], string> = {
|
||||
server: 'this server',
|
||||
browser: 'the browser',
|
||||
both: 'server and browser',
|
||||
};
|
||||
|
||||
/**
|
||||
* Organisation > Security > Third-party traffic (#903). The switch is config,
|
||||
* not policy: it is saved through /api/admin/config like the Server page's
|
||||
* keys, applies to the whole server whatever scope the scope bar shows, and
|
||||
* is read-only when the environment or a read-only config sets it.
|
||||
*/
|
||||
export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
const [state, setState] = useState<OutgoingHostsState | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { setMessage } = draft;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/outgoing-hosts');
|
||||
if (!res.ok) throw new Error();
|
||||
setState(await res.json());
|
||||
setFailed(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function save(on: boolean) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noThirdPartyTraffic: on }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: on
|
||||
? 'Third-party traffic is off. The server stops at once; browsers follow the next time Bulwark loads.'
|
||||
: 'Third-party traffic is allowed again.',
|
||||
});
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not save the setting' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not save the setting' });
|
||||
} finally {
|
||||
await load();
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: 'noThirdPartyTraffic' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Could not revert the setting' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not revert the setting' });
|
||||
} finally {
|
||||
await load();
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const on = !!state?.noThirdPartyTraffic;
|
||||
const locked = !!state && (state.readOnly || state.envLocked);
|
||||
const relays = state?.reachable.filter((h) => h.kind === 'Push relay') ?? [];
|
||||
|
||||
return (
|
||||
<SettingsSection id="third-party" title="Third-party traffic" description="Whether Bulwark connects to anything outside your own servers.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
<p className="text-sm text-muted-foreground" data-testid="third-party-scope-note">
|
||||
This switch is for the whole server, not per domain or group: most of what it stops, such as telemetry and the version check, runs for no particular user.
|
||||
</p>
|
||||
|
||||
{!state && !failed && (
|
||||
<div className="flex items-center py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
)}
|
||||
{failed && <p className="text-sm text-destructive">Could not load the outgoing hosts.</p>}
|
||||
|
||||
{state && (
|
||||
<>
|
||||
<SettingItem
|
||||
label="No third-party traffic"
|
||||
description="Bulwark and your users' browsers stop contacting services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, the connector, sender icons, MyMemory translation, map links and OpenPGP key lookups at other domains. Plugins that declare network access don't run; the others do. The page's security policy then allows only the hosts listed below."
|
||||
>
|
||||
<div className="flex items-center gap-1.5" data-testid="third-party-switch">
|
||||
<SourceBadge source={state.source} label={state.source === 'env' ? 'NO_THIRD_PARTY_TRAFFIC' : 'config.json'} />
|
||||
<ToggleSwitch checked={on} disabled={saving || locked} onChange={(checked) => void save(checked)} />
|
||||
{state.source === 'admin' && !locked && <RevertButton onClick={() => void revert()} />}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{state.envLocked && (
|
||||
<ConfigNote>Set by NO_THIRD_PARTY_TRAFFIC in the environment. Change it there and restart Bulwark.</ConfigNote>
|
||||
)}
|
||||
{state.readOnly && !state.envLocked && (
|
||||
<ConfigNote>The configuration is read-only. Set noThirdPartyTraffic in config.json, or NO_THIRD_PARTY_TRAFFIC in the environment.</ConfigNote>
|
||||
)}
|
||||
{on && relays.length === 0 && (
|
||||
<ConfigNote>
|
||||
Push notifications now need a relay you run yourself. Add one under{' '}
|
||||
<AdminLink href="/admin/policy" className="underline underline-offset-2">User policy > Push relays</AdminLink>;
|
||||
until then users get new mail only while Bulwark is open.
|
||||
</ConfigNote>
|
||||
)}
|
||||
{on && state.networkPlugins.length > 0 && (
|
||||
<ConfigNote>
|
||||
Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}.
|
||||
</ConfigNote>
|
||||
)}
|
||||
|
||||
<div data-testid="third-party-reachable">
|
||||
<h4 className="text-sm font-medium text-foreground">{on ? 'Still reachable' : 'Stays reachable with the switch on'}</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Hosts you configured yourself, and what users enter themselves.</p>
|
||||
{state.reachable.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">None configured.</p>
|
||||
) : (
|
||||
<ul className="mt-2 divide-y divide-border rounded-md border border-border">
|
||||
{state.reachable.map((h) => (
|
||||
<li key={`${h.kind}|${h.host}|${h.setIn}`} className="flex flex-col gap-0.5 px-3 py-2 text-sm sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-3">
|
||||
<span className="font-medium text-foreground sm:w-44 sm:shrink-0">{h.kind}</span>
|
||||
<span className={h.origin ? 'min-w-0 break-all font-mono text-xs text-foreground' : 'min-w-0 text-xs italic text-foreground'}>{h.host}</span>
|
||||
<span className="text-xs text-muted-foreground sm:ms-auto sm:text-end">{h.setIn} · {SIDE_LABEL[h.side]}</span>
|
||||
{h.note && <span className="text-xs text-amber-700 dark:text-amber-400 sm:basis-full">{h.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details data-testid="third-party-builtin">
|
||||
<summary className="cursor-pointer text-sm font-medium text-foreground">
|
||||
Built-in outside hosts ({state.builtIn.length}){on ? ', all off' : ''}
|
||||
</summary>
|
||||
<ul className="mt-2 divide-y divide-border rounded-md border border-border">
|
||||
{state.builtIn.map((h) => (
|
||||
<li key={h.id} className="space-y-0.5 px-3 py-2 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="break-all font-mono text-xs text-foreground">{h.host}</span>
|
||||
<span className="text-xs text-muted-foreground">{h.side === 'server' ? 'this server' : 'the browser'}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{h.usedBy}</p>
|
||||
<p className="text-xs text-muted-foreground">{on ? h.inMode : `Without the switch: ${h.off}`}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { EventDetailPopover } from '../event-detail-popover';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// #903: an address opens Google Maps only while third-party traffic is allowed.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
|
||||
}));
|
||||
|
||||
const calendar = { id: 'cal-1', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar;
|
||||
|
||||
function renderPopover() {
|
||||
const event = {
|
||||
id: 'evt-1', calendarIds: { 'cal-1': true }, title: 'Visit', start: '2026-11-20T10:00:00', duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false,
|
||||
locations: { l1: { name: 'Hauptstrasse 1, Berlin' } },
|
||||
} as unknown as CalendarEvent;
|
||||
render(
|
||||
<EventDetailPopover event={event} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()} />,
|
||||
);
|
||||
return screen.getByTitle('Hauptstrasse 1, Berlin');
|
||||
}
|
||||
|
||||
afterEach(() => setNoThirdPartyTraffic(false));
|
||||
|
||||
describe('event address link', () => {
|
||||
it('opens Google Maps with third-party traffic allowed', () => {
|
||||
expect(renderPopover().getAttribute('href')).toMatch(/^https:\/\/www\.google\.com\/maps\//);
|
||||
});
|
||||
|
||||
it('is plain text with no third-party traffic', () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(renderPopover().getAttribute('href')).toBeNull();
|
||||
});
|
||||
|
||||
it('is plain text while the config is still unknown', () => {
|
||||
setNoThirdPartyTraffic('unknown');
|
||||
expect(renderPopover().getAttribute('href')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
|
||||
@@ -148,6 +149,8 @@ export function EventDetailPopover({
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
|
||||
// Re-computes the location link once the config is known (#903).
|
||||
const noThirdParty = useNoThirdPartyTraffic();
|
||||
|
||||
// Invitations (Teams above all) leave virtualLocations empty and bury the
|
||||
// join URL in the description; surface it as the meeting link anyway.
|
||||
@@ -160,8 +163,10 @@ export function EventDetailPopover({
|
||||
const locationHref = useMemo(() => {
|
||||
if (!locationName) return null;
|
||||
const action = locationAction(locationName, meeting);
|
||||
return action.kind === "url" ? action.uri : mapsUrl(action.query);
|
||||
}, [locationName, meeting]);
|
||||
if (action.kind === "url") return action.uri;
|
||||
// No Google Maps link while third-party traffic is off (#903).
|
||||
return noThirdParty ? null : mapsUrl(action.query);
|
||||
}, [locationName, meeting, noThirdParty]);
|
||||
const [locationCopied, setLocationCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
@@ -493,7 +498,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)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// #903: with no third-party traffic, remote content in mail never loads, so
|
||||
// nothing offers to load it or to trust the sender; the notice says the
|
||||
// organisation blocks it. Without the mode the controls are as before.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ThreadConversationView } = await import('../thread-conversation-view');
|
||||
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }],
|
||||
subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
|
||||
bodyValues: { '1': { value: '<p>Hello</p><img src="https://track.shop.example/pixel.gif">' } },
|
||||
} as unknown as Email;
|
||||
|
||||
afterEach(() => {
|
||||
setNoThirdPartyTraffic(false);
|
||||
useSettingsStore.setState({ externalContentPolicy: 'ask' });
|
||||
});
|
||||
|
||||
async function renderViewer() {
|
||||
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
return screen.findByTestId('external-content-notice');
|
||||
}
|
||||
|
||||
async function renderConversation() {
|
||||
const view = render(
|
||||
<ThreadConversationView thread={{ threadId: 't1', emails: [email], latestEmail: email } as never} emails={[email] as never} onBack={() => {}} />,
|
||||
);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('reading pane', () => {
|
||||
it('offers to load remote content without the mode', async () => {
|
||||
const notice = await renderViewer();
|
||||
expect(notice.textContent).toContain('external_content_warning');
|
||||
expect(screen.queryByText('load_external_content')).not.toBeNull();
|
||||
expect(screen.queryByText('trust_sender')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says the organisation blocks it, with no load or trust button, even when the policy allows', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
const notice = await renderViewer();
|
||||
expect(notice.textContent).toContain('remote_content_blocked');
|
||||
expect(screen.queryByText('load_external_content')).toBeNull();
|
||||
expect(screen.queryByText('trust_sender')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation view', () => {
|
||||
it('offers to load remote content without the mode', async () => {
|
||||
await renderConversation();
|
||||
expect(screen.getByTestId('external-content-notice').textContent).toContain('email_viewer.external_content_warning');
|
||||
expect(screen.queryByText('email_viewer.load_external_content')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says the organisation blocks it, keeps it blocked and offers nothing in the mode', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
const { container } = await renderConversation();
|
||||
expect(screen.getByTestId('external-content-notice').textContent).toContain('third_party.remote_content_blocked');
|
||||
expect(screen.queryByText('email_viewer.load_external_content')).toBeNull();
|
||||
expect(screen.queryByText('email_viewer.trust_sender')).toBeNull();
|
||||
const srcdoc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
|
||||
expect(srcdoc).not.toMatch(/\ssrc="https:\/\/track\.shop\.example/);
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,7 @@ import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
|
||||
/** The More menu's two drill-downs: a folder list and a tag list. */
|
||||
type MoreMenuSub = 'move' | 'tag' | 'rules';
|
||||
@@ -1094,6 +1095,10 @@ export function EmailViewer({
|
||||
const tWelcome = useTranslations('welcome');
|
||||
const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled);
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
// No third-party traffic (#903): remote content never loads, whatever the
|
||||
// policy or trust says, and nothing offers to load it.
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const tThirdParty = useTranslations('third_party');
|
||||
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
|
||||
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
@@ -2498,6 +2503,7 @@ export function EmailViewer({
|
||||
// 'ask' = block until the user allows this message or trusts the sender.
|
||||
const shouldBlockExternal = useMemo(() => {
|
||||
if (!email) return false;
|
||||
if (remoteBlockedByOrg) return true;
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
@@ -2510,7 +2516,7 @@ export function EmailViewer({
|
||||
// permission changes, so they're deliberately omitted from deps (matches
|
||||
// the srcDoc rebuild contract described on `emailContent`).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, externalContentPolicy, allowExternalContent]);
|
||||
}, [email, externalContentPolicy, allowExternalContent, remoteBlockedByOrg]);
|
||||
|
||||
// Sanitize and prepare email HTML content
|
||||
const emailContent = useMemo(() => {
|
||||
@@ -5873,7 +5879,7 @@ export function EmailViewer({
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{(senderVerification ||
|
||||
(hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
(hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
@@ -5903,8 +5909,8 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* External Content Controls */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
{hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
|
||||
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
|
||||
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Image className="w-5 h-5" />
|
||||
</div>
|
||||
@@ -5914,9 +5920,10 @@ export function EmailViewer({
|
||||
External Content
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{t('external_content_warning')}
|
||||
{remoteBlockedByOrg ? tThirdParty('remote_content_blocked') : t('external_content_warning')}
|
||||
</div>
|
||||
</div>
|
||||
{!remoteBlockedByOrg && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<button
|
||||
@@ -5949,6 +5956,7 @@ export function EmailViewer({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -57,6 +57,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
|
||||
export type ConversationAttachmentHandler = (
|
||||
blobId: string, name: string, type: string | undefined, source: EmailBlobContext,
|
||||
@@ -114,6 +115,8 @@ export function useConversationExternalContent() {
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
|
||||
// No third-party traffic (#903): nothing remote loads and nothing offers to.
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
|
||||
const allow = useCallback((emailId: string) => {
|
||||
setAllowedIds(prev => new Set(prev).add(emailId));
|
||||
@@ -124,12 +127,13 @@ export function useConversationExternalContent() {
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
: false;
|
||||
if (remoteBlockedByOrg) return false;
|
||||
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
|
||||
};
|
||||
|
||||
const trustSender = (email: Email) => {
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
if (!senderEmail) return undefined;
|
||||
if (!senderEmail || remoteBlockedByOrg) return undefined;
|
||||
return () => {
|
||||
if (trustedSendersAddressBook && client) {
|
||||
addToTrustedSendersBook(client, senderEmail).catch(console.error);
|
||||
@@ -525,6 +529,7 @@ export function ConversationEmailBody({
|
||||
bodyStyle,
|
||||
className,
|
||||
}: ConversationEmailBodyProps) {
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const t = useTranslations();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
@@ -834,10 +839,11 @@ export function ConversationEmailBody({
|
||||
<div className={className}>
|
||||
{/* External content warning */}
|
||||
{hasBlockedContent && !allowExternal && (
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
|
||||
<span className="text-muted-foreground">
|
||||
{t("email_viewer.external_content_warning")}
|
||||
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
|
||||
</span>
|
||||
{!remoteBlockedByOrg && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -862,6 +868,7 @@ export function ConversationEmailBody({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza
|
||||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { stripBidiControls } from "@/lib/bidi";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
|
||||
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
|
||||
// this preview renders are typed.
|
||||
@@ -43,7 +44,9 @@ function escapeHtml(s: string): string {
|
||||
// (FilePreviewModal); this is pure presentation.
|
||||
export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow";
|
||||
// Always blocked with no third-party traffic (#903).
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
|
||||
|
||||
// dir="auto" on a wrapper we control lets the first strong character pick
|
||||
// the direction, matching the main viewer.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
|
||||
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
|
||||
/**
|
||||
* An HTML attachment as a document for a fully locked frame (sandbox="",
|
||||
@@ -161,7 +162,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
// open-in-new-tab fallback use.
|
||||
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
|
||||
const [htmlDoc, setHtmlDoc] = useState<string | null>(null);
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow";
|
||||
// Always blocked with no third-party traffic (#903).
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
|
||||
|
||||
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
|
||||
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MessageListSettings } from '../message-list-settings';
|
||||
import { NotificationSettings } from '../notification-settings';
|
||||
import { PluginsSettings } from '../plugins-settings';
|
||||
import { ReadingSettings } from '../reading-settings';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// #903: the settings pages hide or explain what the no-third-party mode turns off.
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'en' }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock('@/lib/web-push', () => ({
|
||||
WebPushUnsupportedError: class WebPushUnsupportedError extends Error {},
|
||||
isWebPushSupported: () => true,
|
||||
isWebPushEnabled: vi.fn(async () => false),
|
||||
enableWebPush: vi.fn(async () => ({ subscriptionId: 'sub-1' })),
|
||||
disableWebPush: vi.fn(async () => undefined),
|
||||
listPushDevices: vi.fn(async () => []),
|
||||
mailAccountsWithoutPush: vi.fn(() => []),
|
||||
revokePushDevice: vi.fn(async () => undefined),
|
||||
serverSupportsEmailPush: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ client: { getAccountId: () => 'acct-1' } as never, username: 'me@example.com' });
|
||||
usePolicyStore.setState({
|
||||
...usePolicyStore.getState(),
|
||||
loaded: true,
|
||||
policy: {},
|
||||
isSettingLocked: () => false,
|
||||
isSettingHidden: () => false,
|
||||
isFeatureEnabled: () => true,
|
||||
isPluginForceEnabled: () => false,
|
||||
} as never);
|
||||
useSettingsStore.setState({ senderFavicons: true, emailNotificationsEnabled: true });
|
||||
});
|
||||
afterEach(() => setNoThirdPartyTraffic(false));
|
||||
|
||||
describe('Settings > Message list', () => {
|
||||
it('offers sender favicons only while third-party traffic is allowed', () => {
|
||||
const { unmount } = render(<MessageListSettings />);
|
||||
expect(screen.getByText('sender_favicons.label')).toBeInTheDocument();
|
||||
unmount();
|
||||
setNoThirdPartyTraffic(true);
|
||||
render(<MessageListSettings />);
|
||||
expect(screen.queryByText('sender_favicons.label')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings > Notifications', () => {
|
||||
function pushToggle() {
|
||||
const row = screen.getByText('push.enable').closest('[data-search-label]') as HTMLElement;
|
||||
return row.querySelector('[role="switch"]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it('says push needs an organisation relay and disables the switch when there is none', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
render(<NotificationSettings />);
|
||||
await waitFor(() => expect(screen.getByText('push_needs_relay')).toBeInTheDocument());
|
||||
expect(pushToggle()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps the switch with the built-in relay when the mode is off', async () => {
|
||||
render(<NotificationSettings />);
|
||||
await waitFor(() => expect(screen.getByText('push.status_inactive')).toBeInTheDocument());
|
||||
expect(pushToggle()).not.toBeDisabled();
|
||||
expect(screen.queryByText('push_needs_relay')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings > Plugins', () => {
|
||||
it('says why a plugin that declares network access was not started', () => {
|
||||
const plugin = {
|
||||
id: 'p', name: 'Fetcher', version: '1.0.0', author: 'a', description: 'd', type: 'hook', permissions: ['http:fetch'],
|
||||
entrypoint: 'index.js', enabled: true, status: 'error', error: 'third-party-off', managed: true, settings: {},
|
||||
} as unknown as InstalledPlugin;
|
||||
usePluginStore.setState({ plugins: [plugin], initialized: true, initializePlugins: vi.fn() } as never);
|
||||
render(<PluginsSettings />);
|
||||
fireEvent.click(screen.getByText('Fetcher'));
|
||||
expect(screen.getByText('plugin_refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings > Reading', () => {
|
||||
function externalContentRow() {
|
||||
return screen.getByText('external_content.label').closest('[data-search-label]') as HTMLElement;
|
||||
}
|
||||
|
||||
it('lets the user pick the external content policy without the mode', () => {
|
||||
useSettingsStore.setState({ externalContentPolicy: 'ask' });
|
||||
render(<ReadingSettings />);
|
||||
expect(externalContentRow().textContent).not.toContain('org_locked');
|
||||
expect(externalContentRow().querySelector('select')?.value).toBe('ask');
|
||||
});
|
||||
|
||||
it('shows the policy as blocked and set by the organisation in the mode', () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
render(<ReadingSettings />);
|
||||
const row = externalContentRow();
|
||||
expect(row.textContent).toContain('org_locked');
|
||||
expect(row.querySelector('select')?.value).toBe('block');
|
||||
expect(row.querySelector('[inert]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { MessageListOrderSettings } from './message-list-order-settings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -28,6 +29,8 @@ import type { Mailbox } from '@/lib/jmap/types';
|
||||
*/
|
||||
export function MessageListSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
// Sender icons come from an outside service: no setting for them in the mode (#903).
|
||||
const faviconsAllowed = !useNoThirdPartyTraffic();
|
||||
const tPage = useTranslations('settings.message_list');
|
||||
const tAppearance = useTranslations('settings.appearance');
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
@@ -216,13 +219,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 && faviconsAllowed && (
|
||||
<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 && faviconsAllowed && 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 { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
|
||||
import {
|
||||
resolveActiveRelayUrl,
|
||||
resolvePushRelayOptions,
|
||||
@@ -50,6 +51,9 @@ type DevicesState =
|
||||
export function NotificationSettings() {
|
||||
const t = useTranslations('settings.notifications');
|
||||
const tAppearance = useTranslations('settings.appearance');
|
||||
const tThirdParty = useTranslations('third_party');
|
||||
// The relay list depends on it; re-render once the config is known (#903).
|
||||
const noThirdParty = useNoThirdPartyTraffic();
|
||||
const {
|
||||
emailNotificationsEnabled,
|
||||
emailNotificationSound,
|
||||
@@ -83,6 +87,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 +146,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 +428,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 +440,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') : noThirdParty ? 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 (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ChevronRight } from '@/components/icons';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
|
||||
|
||||
/**
|
||||
* The Reading page: the open message. Opening it, how its body is drawn,
|
||||
@@ -18,6 +19,9 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
* tab. List, delete and swipe rows live on the Message list page.
|
||||
*/
|
||||
export function ReadingSettings() {
|
||||
// No third-party traffic (#903) blocks remote content outright: the
|
||||
// setting shows as the organisation's, set to block.
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const tPage = useTranslations('settings.reading_page');
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
@@ -119,9 +123,9 @@ export function ReadingSettings() {
|
||||
|
||||
<SettingsSection id="images" title={tPage('sections.images')} description={tPage('sections.images_description')}>
|
||||
{isFeatureEnabled('externalContentEnabled') && !isSettingHidden('externalContentPolicy') && (
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy') || remoteBlockedByOrg}>
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
value={remoteBlockedByOrg ? 'block' : externalContentPolicy}
|
||||
onChange={(value) =>
|
||||
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
|
||||
// #903: the mock server's sample photos come from randomuser.me and
|
||||
// img.freepik.com; none of them while third-party traffic is off.
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ devMode: true }) }));
|
||||
|
||||
import { Avatar } from '../avatar';
|
||||
import { avatarHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
const PEOPLE = ['anna', 'ben', 'carla', 'dirk', 'eva', 'finn', 'greta', 'hans'].map((n) => `${n}@gmail.com`);
|
||||
|
||||
function sampleImages(): string[] {
|
||||
const { container, unmount } = render(
|
||||
<div>
|
||||
{PEOPLE.map((email) => <Avatar key={email} name={email} email={email} />)}
|
||||
<Avatar name="Alice" email="alice@example.com" />
|
||||
</div>,
|
||||
);
|
||||
const srcs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src') ?? '');
|
||||
unmount();
|
||||
return srcs.filter((src) => /randomuser\.me|img\.freepik\.com/.test(src));
|
||||
}
|
||||
|
||||
afterEach(() => setNoThirdPartyTraffic(false));
|
||||
|
||||
describe('mock server sample photos', () => {
|
||||
it('show with third-party traffic allowed', () => {
|
||||
expect(sampleImages().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('are left out with no third-party traffic', () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(sampleImages()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin avatars (Gravatar and the like)', () => {
|
||||
afterEach(() => avatarHooks.onAvatarResolve.clear());
|
||||
|
||||
async function renderWithGravatar() {
|
||||
avatarHooks.onAvatarResolve.register('gravatar', (async () => 'https://www.gravatar.com/avatar/abc?d=404') as never);
|
||||
const view = render(<Avatar name="Zoe" email="zoe@corp.example.net" />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return view;
|
||||
}
|
||||
|
||||
it("show the plugin's outside URL with third-party traffic allowed", async () => {
|
||||
const { container } = await renderWithGravatar();
|
||||
await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toMatch(/gravatar\.com/));
|
||||
});
|
||||
|
||||
it('fall back to initials with no third-party traffic', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
const { container } = await renderWithGravatar();
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(container.textContent).toBe('ZO');
|
||||
});
|
||||
});
|
||||
@@ -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,8 @@ 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 { avatarUrlAllowed, sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
@@ -126,7 +128,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
|
||||
@@ -165,6 +167,8 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
|
||||
const faviconRefreshedAt = useFaviconRefreshStore((s) => s.refreshedAt);
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const { devMode } = useConfig();
|
||||
// Re-render when the config arrives: until then nothing outside is asked (#903).
|
||||
useNoThirdPartyTraffic();
|
||||
|
||||
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
|
||||
// Runs whenever email or name changes; resets plugin avatar state on each change.
|
||||
@@ -187,7 +191,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
|
||||
return getContactPhotoIndex(contacts).get(email.toLowerCase()) ?? null;
|
||||
}, [disableImages, contactPhoto, email, contacts]);
|
||||
const { src: contactPhotoSrc, observe } = useContactPhotoSrc(photoRef);
|
||||
const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto ? contactPhotoSrc : undefined;
|
||||
const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto && avatarUrlAllowed(contactPhotoSrc) ? contactPhotoSrc : undefined;
|
||||
|
||||
// A refresh of the sender icons gives a failed favicon another chance.
|
||||
useEffect(() => {
|
||||
@@ -237,13 +241,15 @@ 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 pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const customAvatar = devMode && email && sampleAvatarsAllowed() ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
// Gravatar and the like are outside hosts: initials instead in the no-third-party mode.
|
||||
const pluginAvatar = pluginAvatarFailed || !avatarUrlAllowed(pluginAvatarUrl) ? null : pluginAvatarUrl;
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null;
|
||||
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||
|
||||
+11
-2
@@ -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 {
|
||||
@@ -71,8 +74,10 @@ async function fetchLiteConfig(): Promise<ConfigData> {
|
||||
if (!res.ok) throw new Error(`config.json answered ${res.status}`);
|
||||
return applyLiteConfig(await res.json(), defaults);
|
||||
} catch (err) {
|
||||
console.warn('[lite] config.json missing or invalid, using defaults:', err);
|
||||
return applyLiteConfig({}, defaults);
|
||||
// Fail closed (#903): without a readable config.json nobody said outside
|
||||
// hosts may be used, so the no-third-party switches stay on.
|
||||
console.warn('[lite] config.json missing or invalid, using defaults with no third-party traffic:', err);
|
||||
return applyLiteConfig({ noThirdPartyTraffic: true }, defaults);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +133,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;
|
||||
@@ -186,6 +192,7 @@ export function useConfig(): AppConfig {
|
||||
embeddedMode: configCache?.embeddedMode || false,
|
||||
parentOrigin: configCache?.parentOrigin || '',
|
||||
sourceCodeUrl: configCache?.sourceCodeUrl || '',
|
||||
noThirdPartyTraffic: configCache?.noThirdPartyTraffic ?? false,
|
||||
isLoading: !configCache,
|
||||
error: null,
|
||||
});
|
||||
@@ -231,6 +238,7 @@ export function useConfig(): AppConfig {
|
||||
embeddedMode: configCache.embeddedMode,
|
||||
parentOrigin: configCache.parentOrigin,
|
||||
sourceCodeUrl: configCache.sourceCodeUrl,
|
||||
noThirdPartyTraffic: configCache.noThirdPartyTraffic,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
@@ -277,6 +285,7 @@ export function useConfig(): AppConfig {
|
||||
embeddedMode: data.embeddedMode,
|
||||
parentOrigin: data.parentOrigin,
|
||||
sourceCodeUrl: data.sourceCodeUrl,
|
||||
noThirdPartyTraffic: data.noThirdPartyTraffic,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
|
||||
|
||||
/**
|
||||
* Whether the browser must stay off built-in outside hosts (#903), as React
|
||||
* state: components re-render when the config arrives. True while the config
|
||||
* is still unknown, so nothing reaches out before it says it may.
|
||||
*/
|
||||
export function useNoThirdPartyTraffic(): boolean {
|
||||
return useSyncExternalStore(subscribeThirdPartyMode, noThirdPartyTraffic, () => true);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { test, expect, type BrowserContext, type Page } from '@playwright/test';
|
||||
import { JMAP_URL } from './helpers/config';
|
||||
import { neutralizeDevOverlay, accountSwitcher, emailItem, openComposer, closeComposer } from './helpers/app';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { ORG, ORG_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireOrgEnv, shot } from './helpers/org';
|
||||
|
||||
/**
|
||||
* "No third-party traffic" (organisation feature 5, #903) through the real
|
||||
* UI. The dev server runs with NO_THIRD_PARTY_TRAFFIC=true. Every request the
|
||||
* browser makes while org-alice signs in, reads mail (from a sender whose
|
||||
* domain has a favicon), composes and opens settings, and while the admin
|
||||
* opens the Organisation pages, must go to the webmail itself or the JMAP
|
||||
* server. The server's own requests (telemetry, version check, favicons,
|
||||
* directory) are covered by lib/__tests__/no-third-party-server.test.ts; the
|
||||
* direct calls here check that the routes refuse before reaching out.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const SUBJECT = `Third-party check ${Date.now()}`;
|
||||
const REMOTE_SUBJECT = `Remote picture check ${Date.now()}`;
|
||||
const seededIds: string[] = [];
|
||||
|
||||
const allowedHosts = () => new Set([new URL(contextOptions().baseURL).host, new URL(JMAP_URL).host]);
|
||||
|
||||
/** Records every http(s) request of a context, service worker included. */
|
||||
function recordRequests(context: BrowserContext): string[] {
|
||||
const urls: string[] = [];
|
||||
context.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (/^https?:/i.test(url)) urls.push(url);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
function outside(urls: string[]): string[] {
|
||||
const allowed = allowedHosts();
|
||||
return [...new Set(urls.filter((url) => !allowed.has(new URL(url).host)))];
|
||||
}
|
||||
|
||||
async function signIn(page: Page, email: string): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', ORG_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
requireOrgEnv();
|
||||
// The mode must come from the server's config, not from this spec.
|
||||
const admin = await adminContext(browser);
|
||||
const res = await admin.request.get('/api/admin/outgoing-hosts');
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.noThirdPartyTraffic, 'start the dev server with NO_THIRD_PARTY_TRAFFIC=true').toBe(true);
|
||||
await admin.close();
|
||||
|
||||
// A message from a sender whose domain has a favicon, straight into the
|
||||
// Inbox (SMTP on the test servers doesn't work).
|
||||
const alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
|
||||
const inbox = await alice.mailboxByRole('inbox');
|
||||
const r = await alice.request([['Email/set', {
|
||||
accountId: alice.accountId,
|
||||
create: {
|
||||
m: {
|
||||
mailboxIds: { [inbox!.id]: true },
|
||||
from: [{ name: 'GitHub', email: 'noreply@github.com' }],
|
||||
to: [{ name: 'Org Alice', email: ORG.alice }],
|
||||
subject: SUBJECT,
|
||||
receivedAt: new Date().toISOString(),
|
||||
bodyStructure: { partId: 't', type: 'text/plain' },
|
||||
bodyValues: { t: { value: 'Checking that nothing leaves for an outside host.' } },
|
||||
},
|
||||
// A newsletter with a tracking picture on another host.
|
||||
p: {
|
||||
mailboxIds: { [inbox!.id]: true },
|
||||
from: [{ name: 'Shop News', email: 'news@shop.example' }],
|
||||
to: [{ name: 'Org Alice', email: ORG.alice }],
|
||||
subject: REMOTE_SUBJECT,
|
||||
receivedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
bodyStructure: { partId: 'h', type: 'text/html' },
|
||||
bodyValues: { h: { value: '<p>Our offers this week.</p><img src="https://track.shop.example/pixel.gif" width="1" height="1">' } },
|
||||
},
|
||||
},
|
||||
}, '0']]);
|
||||
const created = r.methodResponses[0][1].created ?? {};
|
||||
for (const key of ['m', 'p']) if (created[key]?.id) seededIds.push(created[key].id);
|
||||
expect(seededIds.length, JSON.stringify(r.methodResponses[0][1])).toBe(2);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (seededIds.length === 0) return;
|
||||
const alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
|
||||
await alice.request([['Email/set', { accountId: alice.accountId, destroy: seededIds }, '0']]);
|
||||
});
|
||||
|
||||
test('signing in, reading, composing and settings reach no outside host', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const urls = recordRequests(context);
|
||||
const page = await context.newPage();
|
||||
await signIn(page, ORG.alice);
|
||||
|
||||
const row = emailItem(page, SUBJECT).first();
|
||||
await expect(row).toBeVisible({ timeout: 30000 });
|
||||
await row.click();
|
||||
await expect(page.locator('[data-plugin-source="email-body"]').first()).toContainText('Checking that nothing leaves', { timeout: 20000 });
|
||||
// Initials, not GitHub's favicon: the avatars never ask /api/favicon.
|
||||
await expect(page.locator('img[src*="/api/favicon"]')).toHaveCount(0);
|
||||
await shot(page, 'thirdparty-reading');
|
||||
|
||||
// Remote pictures: the notice says the organisation blocks them, and
|
||||
// nothing offers to load them or to trust the sender.
|
||||
await emailItem(page, REMOTE_SUBJECT).first().click();
|
||||
const notice = page.getByTestId('external-content-notice').first();
|
||||
await expect(notice).toContainText('Your organisation blocks content from other servers', { timeout: 20000 });
|
||||
await expect(page.getByRole('button', { name: 'Load images' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Always trust this sender' })).toHaveCount(0);
|
||||
await shot(page, 'thirdparty-remote-blocked');
|
||||
|
||||
await openComposer(page);
|
||||
await page.locator('[data-testid="composer-subject"]').fill('Not sent');
|
||||
await page.waitForTimeout(1500);
|
||||
await closeComposer(page);
|
||||
|
||||
await page.goto('/settings/notifications');
|
||||
const pushNote = page.getByText(/Push notifications need a push relay run by your organisation/);
|
||||
await expect(pushNote).toBeVisible({ timeout: 20000 });
|
||||
await pushNote.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'thirdparty-push-settings');
|
||||
await page.goto('/settings/reading');
|
||||
const externalContent = page.locator('[data-search-label="External Content"], [data-search-label="External content"]').first();
|
||||
await expect(externalContent).toContainText('Set by your organisation', { timeout: 20000 });
|
||||
await page.goto('/settings/message_list');
|
||||
await expect(page.getByText(/Sender/).first()).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByText('Sender Favicons', { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
expect(urls.length).toBeGreaterThan(10);
|
||||
expect(urls.some((url) => url.includes('/api/favicon'))).toBe(false);
|
||||
expect(outside(urls)).toEqual([]);
|
||||
|
||||
// Direct calls are refused before the server reaches out.
|
||||
expect((await context.request.get('/api/favicon?domain=github.com')).status()).toBe(403);
|
||||
const translate = await context.request.post('/api/translate', {
|
||||
data: { text: 'Hallo Welt', target: 'en', source: 'de' },
|
||||
headers: { Origin: new URL(contextOptions().baseURL).origin },
|
||||
});
|
||||
expect(translate.status()).toBe(403);
|
||||
expect((await context.request.get('/api/connector/capabilities')).status()).toBe(404);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('the admin sees the switch from the environment and what stays reachable', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const urls = recordRequests(admin);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/organisation');
|
||||
await expect(page.getByRole('heading', { name: 'Domains and groups' }).first()).toBeVisible();
|
||||
await page.goto('/admin/org-security');
|
||||
const section = page.locator('[data-settings-section="third-party"]');
|
||||
await expect(section.getByText('No third-party traffic', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
const toggle = page.getByTestId('third-party-switch').getByRole('switch');
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(toggle).toBeDisabled();
|
||||
await expect(section.getByText('NO_THIRD_PARTY_TRAFFIC', { exact: true })).toBeVisible();
|
||||
await expect(section.getByText(/whole server, not per domain or group/)).toBeVisible();
|
||||
await expect(page.getByTestId('third-party-reachable')).toContainText(new URL(JMAP_URL).host);
|
||||
await section.getByText(/Built-in outside hosts/).click();
|
||||
await expect(page.getByTestId('third-party-builtin')).toContainText('icons.duckduckgo.com');
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'thirdparty-admin-security');
|
||||
|
||||
await page.goto('/admin/marketplace');
|
||||
await expect(page.getByText(/official extension directory is off/).first()).toBeVisible({ timeout: 20000 });
|
||||
await page.waitForTimeout(1500);
|
||||
expect(outside(urls)).toEqual([]);
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
test('the admin section and the user settings work at phone width', async ({ browser }) => {
|
||||
const admin = await adminContext(browser, true);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-security');
|
||||
const section = page.locator('[data-settings-section="third-party"]');
|
||||
await expect(section.getByText('No third-party traffic', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
await shot(page, 'thirdparty-admin-security-phone');
|
||||
await admin.close();
|
||||
|
||||
const context = await browser.newContext(contextOptions(true));
|
||||
const urls = recordRequests(context);
|
||||
const user = await context.newPage();
|
||||
await signIn(user, ORG.alice);
|
||||
await user.goto('/settings/notifications');
|
||||
await user.getByRole('button', { name: 'Notifications' }).or(user.getByRole('link', { name: 'Notifications' })).first().click();
|
||||
const pushNote = user.getByText(/Push notifications need a push relay run by your organisation/);
|
||||
await expect(pushNote).toBeVisible({ timeout: 20000 });
|
||||
await pushNote.scrollIntoViewIfNeeded();
|
||||
await shot(user, 'thirdparty-push-settings-phone');
|
||||
expect(outside(urls)).toEqual([]);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* A plugin that only returns image URLs (Gravatar declares no network
|
||||
* access, so it runs): its avatars must fall back to initials, and the page's
|
||||
* CSP stops anything that slips through. The zip is built from
|
||||
* repos/plugins/gravatar into the rig's scratch folder with the fallback
|
||||
* style set to "identicon"; IT_GRAVATAR_ZIP points at it.
|
||||
*/
|
||||
test('the Gravatar plugin runs but nothing reaches gravatar.com', async ({ browser }) => {
|
||||
const zip = process.env.IT_GRAVATAR_ZIP ?? '';
|
||||
test.skip(!zip || !existsSync(zip), 'IT_GRAVATAR_ZIP not set');
|
||||
const admin = await adminContext(browser);
|
||||
const before = await getPolicy(admin);
|
||||
const features = (before.features ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
const upload = await admin.request.post('/api/admin/plugins', {
|
||||
multipart: { file: { name: 'gravatar.zip', mimeType: 'application/zip', buffer: readFileSync(zip) } },
|
||||
});
|
||||
expect(upload.ok(), await upload.text()).toBe(true);
|
||||
await putPolicy(admin, { ...before, features: { ...features, pluginsEnabled: true }, forceEnabledPlugins: ['gravatar'] });
|
||||
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const urls = recordRequests(context);
|
||||
const page = await context.newPage();
|
||||
await signIn(page, ORG.alice);
|
||||
await expect(emailItem(page, SUBJECT).first()).toBeVisible({ timeout: 30000 });
|
||||
// The plugin is running in this browser. The zip's fallback style is
|
||||
// "identicon", so it returns a gravatar.com URL for every sender without
|
||||
// first asking gravatar.com whether a picture exists (a fetch the
|
||||
// sandbox's own policy stops); that URL is what the avatar must refuse.
|
||||
await page.goto('/settings/addons');
|
||||
await expect(page.getByText('Gravatar Avatars').first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByText('On', { exact: true }).first()).toBeVisible({ timeout: 60000 });
|
||||
// Back inside the app, so the running plugin stays loaded.
|
||||
await page.getByRole('button', { name: 'Back to Mail' }).or(page.getByRole('link', { name: 'Back to Mail' })).first().click();
|
||||
await emailItem(page, SUBJECT).first().click();
|
||||
await expect(page.locator('[data-plugin-source="email-body"]').first()).toContainText('Checking that nothing leaves', { timeout: 20000 });
|
||||
await page.waitForTimeout(3000);
|
||||
await expect(page.locator('img[src*="gravatar.com"]')).toHaveCount(0);
|
||||
await shot(page, 'thirdparty-gravatar-initials');
|
||||
expect(urls.filter((url) => /gravatar\.com/i.test(url))).toEqual([]);
|
||||
expect(outside(urls)).toEqual([]);
|
||||
await context.close();
|
||||
} finally {
|
||||
await putPolicy(admin, before);
|
||||
await admin.request.delete('/api/admin/plugins', { data: { id: 'gravatar' } });
|
||||
await admin.close();
|
||||
}
|
||||
});
|
||||
@@ -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,69 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { BUILT_IN_HOSTNAMES } from '@/lib/outgoing-hosts';
|
||||
import { isPlaceholderHost as sourcePlaceholder } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import { LITE_BUILT_IN_HOSTS, isPlaceholderHost, scanBundleHosts } from '../../scripts/lite/scan-hosts.mjs';
|
||||
|
||||
// scripts/lite/scan-hosts.mjs runs in the Lite CI job after build:lite (the
|
||||
// build prunes the test trees, so the scan can't be a vitest file there).
|
||||
// These tests pin it to the app's host list and show what it catches. With
|
||||
// LITE_OUT_DIR set it also scans a real build:
|
||||
//
|
||||
// LITE_OUT_DIR=/path/to/out npx vitest run lib/__tests__/lite-bundle-hosts.test.ts
|
||||
|
||||
const dirs: string[] = [];
|
||||
function fakeOut(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'lite-out-'));
|
||||
dirs.push(dir);
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
|
||||
writeFileSync(path.join(dir, name), text);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
afterAll(() => { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
describe('Lite bundle host scan', () => {
|
||||
it('knows the same built-in hosts and placeholders as the app', () => {
|
||||
expect((LITE_BUILT_IN_HOSTS as string[]).filter((h) => !h.endsWith('.')).sort()).toEqual([...BUILT_IN_HOSTNAMES].sort());
|
||||
for (const host of ['mail.example.com', 'x', '10.0.0.5', '93.184.216.34', 'acme.test', 'github.com', '*.vimeo.com']) {
|
||||
expect(isPlaceholderHost(host), host).toBe(sourcePlaceholder(host));
|
||||
}
|
||||
});
|
||||
|
||||
it('passes a bundle with only accounted hosts', () => {
|
||||
const out = fakeOut({
|
||||
'config.json': JSON.stringify({ jmapServerUrl: 'https://mail.corp.example.net' }),
|
||||
'_next/static/chunks/a1.js': 'fetch("https://mail.corp.example.net/jmap");x="https://react.dev/errors/";y="https://icons.duckduckgo.com"',
|
||||
'en/mail/index.html': '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||||
});
|
||||
expect(scanBundleHosts(out)).toEqual([]);
|
||||
});
|
||||
|
||||
it('catches a new host, a ws host, a protocol-relative host, a public IP and a listed host in the wrong file', () => {
|
||||
const out = fakeOut({
|
||||
'config.json': '{}',
|
||||
'_next/static/chunks/b2.js': [
|
||||
'fetch("https://beacon.fakecdn.io/x")',
|
||||
'new WebSocket("WSS://Live.FakeCDN.io")',
|
||||
's.src="//cdn.fakecdn.io/lib.js"',
|
||||
'fetch("http://93.184.216.34/")',
|
||||
].join(';'),
|
||||
'en/mail/index.html': '<img src="https://icons.duckduckgo.com/x.ico">',
|
||||
});
|
||||
expect(scanBundleHosts(out).sort()).toEqual([
|
||||
'_next/static/chunks/b2.js: 93.184.216.34',
|
||||
'_next/static/chunks/b2.js: beacon.fakecdn.io',
|
||||
'_next/static/chunks/b2.js: cdn.fakecdn.io',
|
||||
'_next/static/chunks/b2.js: live.fakecdn.io',
|
||||
'en/mail/index.html: icons.duckduckgo.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(!process.env.LITE_OUT_DIR)('finds nothing unaccounted in the build at LITE_OUT_DIR', () => {
|
||||
expect(scanBundleHosts(process.env.LITE_OUT_DIR)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,19 @@ describe('lite build helpers', () => {
|
||||
expect(withScriptHashCsp('<html><script>1</script></html>')).toMatch(/^<html><meta http-equiv="Content-Security-Policy"[^>]*><script>/);
|
||||
});
|
||||
|
||||
it('fences a page built with no third-party traffic to its own origin and the JMAP server (#903)', () => {
|
||||
const strict = buildScriptHashCsp([], { noThirdParty: true, connectSrc: 'https://mail.example.com' });
|
||||
expect(strict).toContain("connect-src 'self' https://mail.example.com");
|
||||
expect(strict).toContain("img-src 'self' data: blob: https://mail.example.com");
|
||||
expect(strict).toContain("font-src 'self' data:");
|
||||
expect(strict).not.toMatch(/ https:( |;|$)/);
|
||||
expect(buildScriptHashCsp([], { noThirdParty: true })).toContain("connect-src 'self' *");
|
||||
const header = buildHeaders({ basePath: '', connectSrc: 'https://mail.example.com', noThirdParty: true });
|
||||
expect(header).toContain("img-src 'self' data: blob: https://mail.example.com");
|
||||
expect(header).not.toMatch(/img-src[^;]* https:( |;)/);
|
||||
expect(buildHeaders({ basePath: '', connectSrc: '*' })).toContain("img-src 'self' data: blob: https:");
|
||||
});
|
||||
|
||||
it('writes a 404 shim that parks deep links for known locale/surface pairs only', () => {
|
||||
const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en'] });
|
||||
expect(html).toContain('var base = "/webmail"');
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_RELAY_BASE_URL, resolveActiveRelayUrl, resolveDefaultRelayUrl, resolvePushRelayOptions } from '@/lib/push-relays';
|
||||
import { resyncWebPush, revokePushDevice, type PushDevice } from '@/lib/web-push';
|
||||
import { doGetPublicKeyFromWKD } from '@/lib/plugin-sandbox/host-api';
|
||||
import { applyLiteConfig } from '@/lib/lite-config';
|
||||
import { setNoThirdPartyTraffic, noThirdPartyTraffic, parseNoThirdPartyEnv } from '@/lib/third-party-mode';
|
||||
import { isBuiltInHost, pluginAllowed, pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
|
||||
import { isPlaceholderHost } from '@/lib/admin/outgoing-hosts-registry';
|
||||
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('push registrations made before the mode', () => {
|
||||
it('are removed from the mail server when no relay of the organisation is left', async () => {
|
||||
vi.stubGlobal('PushManager', class {});
|
||||
vi.stubGlobal('Notification', { permission: 'granted' });
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration: async () => undefined },
|
||||
});
|
||||
localStorage.setItem('bulwark.push.subscriptionId.v1.acc1', 'ps1');
|
||||
const client = {
|
||||
getAccountId: () => 'acc1',
|
||||
destroyPushSubscription: vi.fn(async () => {}),
|
||||
listPushSubscriptions: vi.fn(async () => []),
|
||||
} as unknown as IJMAPClient;
|
||||
try {
|
||||
setNoThirdPartyTraffic(true);
|
||||
await resyncWebPush({ client, relayBaseUrl: '', inboxOnly: false });
|
||||
expect(client.destroyPushSubscription).toHaveBeenCalledWith('ps1');
|
||||
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.acc1')).toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
delete (navigator as unknown as Record<string, unknown>).serviceWorker;
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
// A string as in env counts too (#903 review).
|
||||
expect(applyLiteConfig({ noThirdPartyTraffic: 'yes' }).noThirdPartyTraffic).toBe(true);
|
||||
expect(applyLiteConfig({ noThirdPartyTraffic: 'off' }).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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('before the config is known (#903 review)', () => {
|
||||
it('treats the mode as on', async () => {
|
||||
vi.resetModules();
|
||||
const mode = await import('@/lib/third-party-mode');
|
||||
const hosts = await import('@/lib/outgoing-hosts');
|
||||
expect(mode.thirdPartyModeState()).toBe('unknown');
|
||||
expect(mode.noThirdPartyTraffic()).toBe(true);
|
||||
expect(hosts.relayAllowed('https://notifications.relay.bulwarkmail.org')).toBe(false);
|
||||
expect(hosts.senderFaviconsAllowed()).toBe(false);
|
||||
expect(hosts.mapsLinksAllowed()).toBe(false);
|
||||
expect(hosts.pluginAllowed({ permissions: ['http:fetch'] })).toBe(false);
|
||||
mode.setNoThirdPartyTraffic(false);
|
||||
expect(hosts.relayAllowed('https://notifications.relay.bulwarkmail.org')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', async () => new Response('', { status: 404 })],
|
||||
['not JSON', async () => new Response('{ this is not json', { status: 200 })],
|
||||
])('turns the mode on in Lite when config.json is %s', async (_label, answer) => {
|
||||
vi.resetModules();
|
||||
vi.doMock('@/lib/lite', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/lib/lite')>()), IS_LITE: true, IS_LITE_STALWART: false }));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
fetchSpy.mockImplementation(answer as never);
|
||||
try {
|
||||
const mode = await import('@/lib/third-party-mode');
|
||||
const { fetchConfig } = await import('@/hooks/use-config');
|
||||
expect((await fetchConfig()).noThirdPartyTraffic).toBe(true);
|
||||
expect(mode.thirdPartyModeState()).toBe('on');
|
||||
expect(String(warn.mock.calls[0]?.[0])).toMatch(/no third-party traffic/);
|
||||
} finally {
|
||||
vi.doUnmock('@/lib/lite');
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin view', () => {
|
||||
it('reports the switch, its source, what stays reachable and the refused plugins', async () => {
|
||||
registryPlugins.push(
|
||||
{ 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'] },
|
||||
);
|
||||
const { GET } = await import('@/app/api/admin/outgoing-hosts/route');
|
||||
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: true, jmapServerUrl: 'https://mail.corp.example' }));
|
||||
writeFileSync(path.join(dir, 'policy.json'), JSON.stringify({ pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }] }));
|
||||
await configManager.load();
|
||||
const body = await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json();
|
||||
expect(body).toMatchObject({ noThirdPartyTraffic: true, source: 'admin', envLocked: false, readOnly: false });
|
||||
expect(body.reachable.map((h: { host: string }) => h.host)).toEqual(['mail.corp.example', 'push.corp.example', 'any address your users enter', 'any address your users enter']);
|
||||
expect(body.networkPlugins).toEqual([{ id: 'fetcher', name: 'Fetcher' }]);
|
||||
expect(body.builtIn.length).toBeGreaterThan(5);
|
||||
|
||||
process.env.NO_THIRD_PARTY_TRAFFIC = 'true';
|
||||
expect(await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json()).toMatchObject({ source: 'env', envLocked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('review follow-ups', () => {
|
||||
it('counts a string "true", "1", "yes" or "on" in config.json as on', async () => {
|
||||
for (const value of ['true', '1', 'yes', 'on', 'TRUE']) {
|
||||
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: value }));
|
||||
await configManager.load();
|
||||
expect(await isNoThirdPartyTrafficOn(), value).toBe(true);
|
||||
expect(noThirdPartyTrafficState(), value).toEqual({ on: true, source: 'admin' });
|
||||
}
|
||||
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: 'no' }));
|
||||
await configManager.load();
|
||||
expect(await isNoThirdPartyTrafficOn()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the version check scheduled in the mode, so it resumes once the switch is off', async () => {
|
||||
const { startScheduler, stopScheduler, checkOnce, loadState, saveState } = await import('@/lib/version-check');
|
||||
fetchMock.mockImplementation(async () => Response.json({ latest: '1.18.0', severity: 'none' }));
|
||||
try {
|
||||
await setMode(true);
|
||||
await saveState({ ...(await loadState()), nextScheduledAt: null });
|
||||
await startScheduler();
|
||||
expect((await loadState()).nextScheduledAt).not.toBeNull();
|
||||
await stopScheduler();
|
||||
expect((await checkOnce()).ok).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await setMode(false);
|
||||
await checkOnce();
|
||||
expect(calledHosts()).toEqual(['version.telemetry.bulwarkmail.org']);
|
||||
} finally {
|
||||
await stopScheduler();
|
||||
fetchMock.mockImplementation(async () => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
|
||||
}
|
||||
});
|
||||
|
||||
it('makes getDirectoryUrl refuse the official directory in the mode', async () => {
|
||||
const { getDirectoryUrl, DirectoryError } = await import('@/lib/admin/marketplace-directory');
|
||||
await setMode(true);
|
||||
await expect(getDirectoryUrl()).rejects.toBeInstanceOf(DirectoryError);
|
||||
await expect(getDirectoryUrl()).rejects.toMatchObject({ status: 403 });
|
||||
await setMode(false);
|
||||
expect(await getDirectoryUrl()).toBe('https://extensions.bulwarkmail.org');
|
||||
});
|
||||
|
||||
it('leaves the sample contact photos out of the mock server in the mode', async () => {
|
||||
process.env.DEV_MOCK_JMAP = 'true';
|
||||
process.env.NEXT_PUBLIC_DEV_MOCK_JMAP = 'true';
|
||||
try {
|
||||
const { POST } = await import('@/app/api/dev-jmap/[...path]/route');
|
||||
const photos = async () => {
|
||||
const res = await POST(
|
||||
new NextRequest('http://localhost:3000/api/dev-jmap/api', { method: 'POST', body: JSON.stringify({ using: [], methodCalls: [['ContactCard/get', {}, '0']] }) }),
|
||||
{ params: Promise.resolve({ path: ['api'] }) },
|
||||
);
|
||||
const list = (await res.json()).methodResponses[0][1].list as Array<{ media?: Record<string, { uri?: string }> }>;
|
||||
return list.flatMap((c) => Object.values(c.media ?? {}).map((m) => m.uri ?? '')).filter((uri) => uri.includes('randomuser.me'));
|
||||
};
|
||||
await setMode(false);
|
||||
expect((await photos()).length).toBeGreaterThan(0);
|
||||
await setMode(true);
|
||||
expect(await photos()).toEqual([]);
|
||||
} finally {
|
||||
delete process.env.DEV_MOCK_JMAP;
|
||||
delete process.env.NEXT_PUBLIC_DEV_MOCK_JMAP;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, 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';
|
||||
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
|
||||
import {
|
||||
isBuiltInHost,
|
||||
mapsLinksAllowed,
|
||||
sampleAvatarsAllowed,
|
||||
senderFaviconsAllowed,
|
||||
wkdAllowed,
|
||||
} from '@/lib/outgoing-hosts';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
|
||||
// Turns "no third-party traffic" on the way an admin does (config.json; the
|
||||
// browser gets it from /api/config) and checks that every entry of the
|
||||
// registry is then off, by asking the code that uses each host. A new entry
|
||||
// without a probe here fails, so the registry can't list a host whose
|
||||
// switch nobody checked (#903).
|
||||
|
||||
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 }));
|
||||
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
let dir: string;
|
||||
const fetchMock = vi.fn(async (_url: unknown, _init?: unknown) => Response.json({ latest: '1.18.0' }));
|
||||
const fetchedHosts = () => fetchMock.mock.calls.map(([url]) => new URL(String(url)).hostname);
|
||||
|
||||
async function setMode(on: boolean) {
|
||||
writeFileSync(path.join(dir, 'config.json'), JSON.stringify(on ? { noThirdPartyTraffic: true } : {}));
|
||||
await configManager.load();
|
||||
// What the browser does with /api/config (hooks/use-config.ts).
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
const config = await (await GET(new NextRequest('https://webmail.example/api/config'))).json();
|
||||
setNoThirdPartyTraffic(config.noThirdPartyTraffic === true);
|
||||
}
|
||||
|
||||
/** Whether the code behind each registry entry would reach its host right now. */
|
||||
const probes: Record<string, () => Promise<boolean>> = {
|
||||
telemetry: async () => {
|
||||
const { effectiveConsent, loadState, saveState } = await import('@/lib/telemetry');
|
||||
await saveState({ ...(await loadState()), consent: 'on' });
|
||||
return (await effectiveConsent()).consent === 'on';
|
||||
},
|
||||
'version-check': async () => {
|
||||
const { checkOnce } = await import('@/lib/version-check');
|
||||
await checkOnce();
|
||||
return fetchedHosts().includes('version.telemetry.bulwarkmail.org');
|
||||
},
|
||||
'push-relay': async () => {
|
||||
const { resolvePushRelayOptions, resolveDefaultRelayUrl } = await import('@/lib/push-relays');
|
||||
return resolvePushRelayOptions({}).some((o) => isBuiltInHost(o.url)) || isBuiltInHost(resolveDefaultRelayUrl({}));
|
||||
},
|
||||
'extension-directory': async () => {
|
||||
const { directoryOff } = await import('@/lib/admin/marketplace-directory');
|
||||
return !(await directoryOff());
|
||||
},
|
||||
connector: async () => {
|
||||
const { GET } = await import('@/app/api/connector/capabilities/route');
|
||||
return (await GET()).status === 200;
|
||||
},
|
||||
favicons: async () => {
|
||||
const { GET } = await import('@/app/api/favicon/route');
|
||||
await GET(new NextRequest(`https://webmail.example/api/favicon?domain=probe${Date.now()}.org`));
|
||||
return fetchedHosts().includes('icons.duckduckgo.com') || senderFaviconsAllowed();
|
||||
},
|
||||
translate: async () => {
|
||||
const { POST } = await import('@/app/api/translate/route');
|
||||
await POST(new NextRequest('https://webmail.example/api/translate', {
|
||||
method: 'POST', body: JSON.stringify({ text: 'Hallo Welt', target: 'en', source: 'de' }),
|
||||
}));
|
||||
return fetchedHosts().includes('api.mymemory.translated.net');
|
||||
},
|
||||
maps: async () => mapsLinksAllowed(),
|
||||
wkd: async () => wkdAllowed(),
|
||||
'dev-avatars': async () => sampleAvatarsAllowed(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-3p-mode-'));
|
||||
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');
|
||||
delete process.env.NO_THIRD_PARTY_TRAFFIC;
|
||||
});
|
||||
afterAll(() => {
|
||||
setNoThirdPartyTraffic(false);
|
||||
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);
|
||||
});
|
||||
|
||||
describe('no third-party traffic mode', () => {
|
||||
it('has a probe for every registry entry', () => {
|
||||
expect(Object.keys(probes).sort()).toEqual(OUTGOING_HOSTS.map((h) => h.id).sort());
|
||||
});
|
||||
|
||||
it.each(OUTGOING_HOSTS.map((h) => h.id))('%s is reachable with the mode off', async (id) => {
|
||||
await setMode(false);
|
||||
expect(await probes[id]()).toBe(true);
|
||||
});
|
||||
|
||||
it.each(OUTGOING_HOSTS.map((h) => h.id))('%s is off with the mode on', async (id) => {
|
||||
await setMode(true);
|
||||
expect(await probes[id]()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BUILT_URL_FILES,
|
||||
NON_REQUEST_HOSTS,
|
||||
NON_REQUEST_PATHS,
|
||||
OUTGOING_HOSTS,
|
||||
isPlaceholderHost,
|
||||
registeredLiterals,
|
||||
registryHostnames,
|
||||
} from '@/lib/admin/outgoing-hosts-registry';
|
||||
import { BUILT_IN_HOSTNAMES } from '@/lib/outgoing-hosts';
|
||||
|
||||
// Every host literal in the shipped source must be a built-in outside host
|
||||
// in lib/admin/outgoing-hosts-registry.ts (with a switch that turns it off)
|
||||
// or a known non-request (docs link, namespace, sample text), in the file it
|
||||
// is listed for. URLs built from a variable must be in a file listed with a
|
||||
// reason. A new outside host fails here until someone decides which it is
|
||||
// (#903).
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const DIRS = ['app', 'lib', 'stores', 'hooks', 'components', 'contexts', 'i18n'];
|
||||
const FILES = ['public/sw.js', 'proxy.ts', 'instrumentation.ts', 'instrumentation.node.ts', 'next.config.ts'];
|
||||
const EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
||||
|
||||
/** scheme://host, for http(s) and ws(s), any case. */
|
||||
const SCHEME_HOST = /\b(?:https?|wss?):\/\/([a-z0-9.*_-]+)/gi;
|
||||
/** scheme:// followed by ${...} or the end of the string: a URL built from a variable, or the scheme alone. */
|
||||
const BUILT_URL = /\b(?:https?|wss?):\/\/(?=\$\{|["'`])/gi;
|
||||
/** A protocol-relative //host at the start of a string literal. */
|
||||
const PROTOCOL_RELATIVE = /["'`]\/\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
||||
|
||||
function isTestPath(rel: string): boolean {
|
||||
return /(^|\/)(__tests__|__mocks__|node_modules)\//.test(rel) || /\.(test|spec)\.[a-z]+$/.test(rel);
|
||||
}
|
||||
|
||||
function walk(rel: string, out: string[]) {
|
||||
const abs = path.join(ROOT, rel);
|
||||
for (const name of readdirSync(abs)) {
|
||||
const child = `${rel}/${name}`;
|
||||
if (isTestPath(`${child}/`) || name === 'node_modules') continue;
|
||||
if (statSync(path.join(abs, name)).isDirectory()) walk(child, out);
|
||||
else if (EXTENSIONS.test(name) && !isTestPath(child)) out.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
const out: string[] = [];
|
||||
for (const dir of DIRS) walk(dir, out);
|
||||
for (const file of FILES) {
|
||||
try { statSync(path.join(ROOT, file)); out.push(file); } catch { /* not in this checkout */ }
|
||||
}
|
||||
return out.filter((file) => !NON_REQUEST_PATHS.some((prefix) => file.startsWith(prefix)));
|
||||
}
|
||||
|
||||
type Finding = { kind: 'host'; host: string } | { kind: 'built' };
|
||||
|
||||
/** Host literals (lower case) and built URLs in a source text. */
|
||||
function findings(text: string): Finding[] {
|
||||
const out: Finding[] = [];
|
||||
for (const m of text.matchAll(SCHEME_HOST)) out.push({ kind: 'host', host: m[1].toLowerCase() });
|
||||
for (const m of text.matchAll(PROTOCOL_RELATIVE)) out.push({ kind: 'host', host: m[1].toLowerCase() });
|
||||
for (const _m of text.matchAll(BUILT_URL)) out.push({ kind: 'built' });
|
||||
return out;
|
||||
}
|
||||
|
||||
const registered = registeredLiterals();
|
||||
|
||||
/** Why a finding in `file` is fine, or null when it isn't accounted for. */
|
||||
function classify(finding: Finding, file: string): string | null {
|
||||
if (finding.kind === 'built') return BUILT_URL_FILES.some((b) => b.file === file) ? 'built' : null;
|
||||
const host = finding.host;
|
||||
const trimmed = host.replace(/\.{2,}$/, '');
|
||||
for (const literal of [host, trimmed]) {
|
||||
if (registered.get(literal)?.has(file)) return 'registry';
|
||||
if (NON_REQUEST_HOSTS.some((entry) => entry.host === literal && entry.files.includes(file))) return 'non-request';
|
||||
}
|
||||
// A registered or allowed host is only fine where it is listed.
|
||||
if (registered.has(trimmed) || NON_REQUEST_HOSTS.some((entry) => entry.host === trimmed)) return null;
|
||||
if (isPlaceholderHost(trimmed)) return 'placeholder';
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('outgoing host literals', () => {
|
||||
const files = sourceFiles();
|
||||
const texts = new Map(files.map((file) => [file, readFileSync(path.join(ROOT, file), 'utf8')]));
|
||||
|
||||
it('scans the shipped source, service worker and config included, and leaves the tests out', () => {
|
||||
for (const file of ['public/sw.js', 'next.config.ts', 'app/api/favicon/route.ts', 'app/api/dev-jmap/[...path]/route.ts', 'i18n/request.ts']) {
|
||||
expect(files).toContain(file);
|
||||
}
|
||||
expect(files.some((f) => f.includes('__tests__'))).toBe(false);
|
||||
expect(files.some((f) => f.startsWith('lib/demo/fixtures/'))).toBe(false);
|
||||
expect(files.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it('finds no host or built URL that isn\'t accounted for', () => {
|
||||
const unknown: string[] = [];
|
||||
for (const [file, text] of texts) {
|
||||
for (const finding of findings(text)) {
|
||||
if (!classify(finding, file)) unknown.push(`${file}: ${finding.kind === 'host' ? finding.host : 'URL built from a variable'}`);
|
||||
}
|
||||
}
|
||||
expect([...new Set(unknown)], 'Add the host to OUTGOING_HOSTS with a switch, or to NON_REQUEST_HOSTS / BUILT_URL_FILES with the reason').toEqual([]);
|
||||
});
|
||||
|
||||
it('lists only hosts and files that are still in the source', () => {
|
||||
const hostsIn = (file: string) => findings(texts.get(file) ?? '')
|
||||
.flatMap((f) => (f.kind === 'host' ? [f.host, f.host.replace(/\.{2,}$/, '')] : []));
|
||||
for (const entry of OUTGOING_HOSTS) {
|
||||
for (const file of entry.files) {
|
||||
const found = (entry.literals ?? [entry.host]).some((literal) => hostsIn(file).includes(literal));
|
||||
expect(found, `${entry.id} in ${file}`).toBe(true);
|
||||
}
|
||||
}
|
||||
for (const entry of NON_REQUEST_HOSTS) {
|
||||
for (const file of entry.files) expect(hostsIn(file).includes(entry.host), `${entry.host} in ${file}`).toBe(true);
|
||||
}
|
||||
for (const { file } of BUILT_URL_FILES) {
|
||||
expect(findings(texts.get(file) ?? '').some((f) => f.kind === 'built'), `built URL in ${file}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the browser\'s host list equal to the registry', () => {
|
||||
expect([...BUILT_IN_HOSTNAMES].sort()).toEqual(registryHostnames().sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scan catches', () => {
|
||||
const unknownIn = (text: string, file = 'lib/new-module.ts') =>
|
||||
findings(text).filter((f) => !classify(f, file)).map((f) => (f.kind === 'host' ? f.host : 'built'));
|
||||
|
||||
it('a new outside host, in any case', () => {
|
||||
expect(unknownIn(`fetch('https://tracker.fakecdn.io/pixel.gif')`)).toEqual(['tracker.fakecdn.io']);
|
||||
expect(unknownIn(`fetch("HTTPS://Tracker.FakeCDN.io/p")`)).toEqual(['tracker.fakecdn.io']);
|
||||
expect(unknownIn(`new WebSocket('wss://live.fakecdn.io/socket')`)).toEqual(['live.fakecdn.io']);
|
||||
expect(unknownIn(`new WebSocket("ws://live.fakecdn.io")`)).toEqual(['live.fakecdn.io']);
|
||||
});
|
||||
|
||||
it('a protocol-relative host in a string', () => {
|
||||
expect(unknownIn(`script.src = '//cdn.fakecdn.io/lib.js'`)).toEqual(['cdn.fakecdn.io']);
|
||||
expect(unknownIn(`// a comment about //cdn.fakecdn.io`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a URL built from a variable or the scheme alone, outside the listed files', () => {
|
||||
expect(unknownIn('fetch(`https://${host}/x`)')).toEqual(['built']);
|
||||
expect(unknownIn(`url.startsWith('https://')`)).toEqual(['built']);
|
||||
expect(unknownIn('fetch(`https://${host}/x`)', 'lib/validation.ts')).toEqual([]);
|
||||
});
|
||||
|
||||
it('a public IP literal, but not a private or reserved one', () => {
|
||||
expect(unknownIn(`fetch('http://93.184.216.34/x')`)).toEqual(['93.184.216.34']);
|
||||
expect(unknownIn(`fetch('http://10.0.0.5:8080'); fetch('http://192.168.1.1'); fetch('http://127.0.0.1')`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a registered or allowed host in a file it isn\'t listed for', () => {
|
||||
expect(unknownIn(`fetch('https://icons.duckduckgo.com/ip3/x.ico')`)).toEqual(['icons.duckduckgo.com']);
|
||||
expect(unknownIn(`<a href="https://github.com/x">`)).toEqual(['github.com']);
|
||||
expect(unknownIn(`fetch('https://icons.duckduckgo.com/ip3/x.ico')`, 'app/api/favicon/route.ts')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { setNoThirdPartyTraffic } from '../third-party-mode';
|
||||
|
||||
// #903: the host API's own locks, behind the loader that already refuses
|
||||
// plugins declaring network access: api.http.fetch and the WKD key lookup
|
||||
// reach nothing outside while third-party traffic is off.
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } }));
|
||||
|
||||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||||
|
||||
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
return {
|
||||
id: 'net', name: 'Net', version: '1.0.0', author: '', description: '', type: 'hook',
|
||||
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const fetchSpy = vi.fn(async () => new Response('{}', { headers: { 'content-type': 'application/json' } }));
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockClear();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setNoThirdPartyTraffic(false);
|
||||
});
|
||||
|
||||
describe('api.http.fetch', () => {
|
||||
const fetcher = plugin({ permissions: ['http:fetch'], grantedPermissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] });
|
||||
|
||||
it('is refused with no third-party traffic, even for a plugin that got started', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
await expect(dispatchApiCall(fetcher, 'http.fetch', ['https://api.vendor.example/x'])).rejects.toThrow(/Outside requests are off/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reaches the declared origin with the mode off', async () => {
|
||||
await dispatchApiCall(fetcher, 'http.fetch', ['https://api.vendor.example/x']).catch(() => undefined);
|
||||
expect(String((fetchSpy.mock.calls[0] as unknown[] | undefined)?.[0])).toMatch(/^https:\/\/api\.vendor\.example\/x/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.crypto.getPublicKeyFromWKD', () => {
|
||||
const pgp = plugin({ permissions: ['crypto:full'], grantedPermissions: ['crypto:full'] });
|
||||
|
||||
it('asks nobody with no third-party traffic', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(await dispatchApiCall(pgp, 'crypto.getPublicKeyFromWKD', ['joe@vendor.example'], { privileged: true })).toMatchObject({ status: 'ERROR' });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks the recipient\'s domain with the mode off', async () => {
|
||||
fetchSpy.mockImplementationOnce(async () => new Response('', { status: 404 }));
|
||||
await dispatchApiCall(pgp, 'crypto.getPublicKeyFromWKD', ['joe@vendor.example'], { privileged: true });
|
||||
expect(String((fetchSpy.mock.calls[0] as unknown[])[0])).toMatch(/^https:\/\/openpgpkey\.vendor\.example\//);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* With no third-party traffic (#903) the page's CSP lists only its own origin
|
||||
* and the hosts the deployment configured, so anything the in-app switches
|
||||
* miss (a privileged plugin, a Gravatar URL) is still stopped by the browser.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
let policy: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: vi.fn(async () => {}),
|
||||
get: vi.fn((key: string, fallback: unknown) => (key in config ? config[key] : fallback)),
|
||||
getPolicy: vi.fn(() => policy),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') }));
|
||||
vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) }));
|
||||
vi.mock('@/lib/wopi/discovery', () => ({ getWopiEditorOrigins: vi.fn(async () => []) }));
|
||||
vi.mock('next-intl/middleware', async () => {
|
||||
const { NextResponse } = await import('next/server');
|
||||
return { default: () => () => NextResponse.next() };
|
||||
});
|
||||
|
||||
import { proxy } from '@/proxy';
|
||||
|
||||
async function csp(): Promise<Record<string, string>> {
|
||||
const res = await proxy(new NextRequest('http://localhost:3000/en/mail', { headers: { 'sec-fetch-dest': 'document' } }));
|
||||
const header = res.headers.get('content-security-policy') ?? '';
|
||||
return Object.fromEntries(header.split('; ').map((d) => [d.split(' ')[0], d.slice(d.indexOf(' ') + 1)]));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(config)) delete config[key];
|
||||
policy = { features: { pluginsEnabled: false }, pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }], defaultSidebarApps: [] };
|
||||
config.jmapServerUrl = 'https://mail.corp.example';
|
||||
});
|
||||
|
||||
describe('page CSP with no third-party traffic', () => {
|
||||
it('lists only the own origin and the configured hosts', async () => {
|
||||
config.noThirdPartyTraffic = true;
|
||||
const d = await csp();
|
||||
expect(d['connect-src']).toBe("'self' https://mail.corp.example https://push.corp.example");
|
||||
expect(d['img-src']).toBe("'self' data: blob: https://mail.corp.example https://push.corp.example");
|
||||
expect(d['font-src']).toBe("'self' data: https://mail.corp.example https://push.corp.example");
|
||||
expect(d['media-src']).toBe("'self' blob: https://mail.corp.example https://push.corp.example");
|
||||
expect(d['frame-src']).toContain('https://mail.corp.example');
|
||||
for (const directive of ['connect-src', 'img-src', 'font-src', 'media-src', 'frame-src']) {
|
||||
expect(d[directive], directive).not.toMatch(/(^| )https?:( |$)|gravatar|bulwarkmail\.org/);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps https: in connect-src only when users may sign in to any server', async () => {
|
||||
config.noThirdPartyTraffic = true;
|
||||
config.allowCustomJmapEndpoint = true;
|
||||
const d = await csp();
|
||||
expect(d['connect-src']).toMatch(/ https:( |$)/);
|
||||
expect(d['img-src']).not.toMatch(/ https:( |$)/);
|
||||
});
|
||||
|
||||
it('stays as before with the mode off', async () => {
|
||||
const d = await csp();
|
||||
expect(d['img-src']).toBe("'self' data: blob: https:");
|
||||
expect(d['connect-src']).toContain('https:');
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
revokePushDevice,
|
||||
waitForWebPushResync,
|
||||
} from '@/lib/web-push';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
|
||||
const RELAY = 'https://relay.example';
|
||||
const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`;
|
||||
const ACCOUNT_ID = 'acct-1';
|
||||
const THIS_DEVICE = 'aaaaaaaabbbbbbbbccccccccdddddddd';
|
||||
const OTHER_DEVICE = '11111111222222223333333344444444';
|
||||
@@ -469,6 +472,52 @@ describe('enableWebPush', () => {
|
||||
// Registrations made before the client learned about the delivery filter are
|
||||
// repaired in the background on app start - nobody should have to find the
|
||||
// settings toggle to stop the spam pushes.
|
||||
describe('resyncWebPush when the relay changed (#903)', () => {
|
||||
afterEach(() => setNoThirdPartyTraffic(false));
|
||||
|
||||
it('moves a registration made with the built-in relay to the admin relay with no third-party traffic', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
localStorage.setItem(RELAY_BASE_KEY, DEFAULT_RELAY_BASE_URL);
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE, { url: `${DEFAULT_RELAY_BASE_URL}/api/push/jmap/${THIS_DEVICE}` })]);
|
||||
const calls = installFetch({});
|
||||
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
|
||||
// A new server record that points at the admin relay replaces the old one.
|
||||
expect((client.createPushSubscription as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({ url: `${RELAY}/api/push/jmap/${THIS_DEVICE}` });
|
||||
expect(client.destroyed).toContain('push-old');
|
||||
expect(client.updatePushSubscription).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY);
|
||||
expect(calls.some((c) => c.url.startsWith(DEFAULT_RELAY_BASE_URL))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a registration from before the relay was recorded as the built-in one in that mode', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
|
||||
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(client.destroyed).toContain('push-old');
|
||||
});
|
||||
|
||||
it('re-registers inside the daily limit when the admin switched relays, and only once', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
|
||||
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
|
||||
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resyncWebPush', () => {
|
||||
it('repairs a lost browser subscription for an account that previously opted in', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
|
||||
@@ -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,241 @@
|
||||
/**
|
||||
* Every outside host Bulwark itself knows about, what talks to it, and what
|
||||
* turns it off (#903). The admin view (/api/admin/outgoing-hosts) and the
|
||||
* tests read this; the browser only needs the host names and the switches in
|
||||
* lib/outgoing-hosts.ts, so none of this prose ends up in client chunks.
|
||||
*
|
||||
* lib/__tests__/outgoing-hosts.test.ts scans the source for host literals and
|
||||
* fails on any that isn't accounted for here, in the file it is listed for.
|
||||
* lib/__tests__/outgoing-hosts-mode.test.ts turns the mode on and checks
|
||||
* every entry's switch.
|
||||
*/
|
||||
|
||||
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 the scheme, when it differs from `host`. */
|
||||
literals?: string[];
|
||||
/** The files that may name it. */
|
||||
files: 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',
|
||||
files: ['lib/telemetry/types.ts'],
|
||||
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',
|
||||
files: ['lib/version-check/types.ts'],
|
||||
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. Checks resume when the switch goes off.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'push-relay',
|
||||
host: 'notifications.relay.bulwarkmail.org',
|
||||
files: ['lib/push-relays.ts', 'app/api/dev-jmap/[...path]/route.ts'],
|
||||
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; existing registrations move to it or are removed.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'extension-directory',
|
||||
host: 'extensions.bulwarkmail.org',
|
||||
files: ['lib/admin/directory-keys.ts', 'lib/admin/marketplace-directory.ts', 'lib/admin/types.ts'],
|
||||
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',
|
||||
files: ['lib/admin/types.ts', 'lib/connector/registry.ts'],
|
||||
side: 'server',
|
||||
usedBy: 'Links in the docs and the directory that open this instance; the connector asks this server what it can open.',
|
||||
off: 'CONNECTOR_ENABLED=false.',
|
||||
inMode: 'The capabilities endpoint the connector asks answers 404.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'favicons',
|
||||
host: 'icons.duckduckgo.com',
|
||||
files: ['app/api/favicon/route.ts'],
|
||||
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',
|
||||
files: ['app/api/translate/route.ts'],
|
||||
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',
|
||||
files: ['lib/event-links.ts'],
|
||||
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.'],
|
||||
files: ['lib/plugin-sandbox/host-api.ts'],
|
||||
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'],
|
||||
files: ['components/ui/avatar.tsx', 'app/api/dev-jmap/[...path]/route.ts'],
|
||||
side: 'browser',
|
||||
usedBy: 'Sample sender and contact 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'] },
|
||||
{ host: 'randomuser.me', why: 'docs links in a comment of the mock server', files: ['app/api/dev-jmap/[...path]/route.ts'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Files that build an http(s) URL from a variable or name the scheme alone
|
||||
* (for checks and placeholders). The host there comes from configuration,
|
||||
* user input or the data around it, never from a built-in default; each is
|
||||
* listed with the reason.
|
||||
*/
|
||||
export const BUILT_URL_FILES: readonly { file: string; why: string }[] = [
|
||||
{ file: 'app/(main)/admin/_pages/marketplace-detail.tsx', why: "link to an extension's source host, from the directory's answer" },
|
||||
{ file: 'app/(main)/setup/page.tsx', why: "the instance's own origin, and scheme checks of the admin-entered server URL" },
|
||||
{ file: 'app/api/plugin-secrets/route.ts', why: "scheme check of a credential's server URL" },
|
||||
{ file: 'components/calendar/ical-subscription-modal.tsx', why: "webcal:// rewritten to https:// for a feed the user enters" },
|
||||
{ file: 'lib/admin/branding-asset.ts', why: "scheme checks of admin-configured branding URLs" },
|
||||
{ file: 'lib/auth/pair-bundle.ts', why: "scheme checks of the paired server URL" },
|
||||
{ file: 'lib/calendar-subscription-sync.ts', why: "webcal:// rewritten to https:// for a feed the user subscribed to" },
|
||||
{ file: 'lib/email-sanitization.ts', why: "comment about links in mail" },
|
||||
{ file: 'lib/idn.ts', why: "parses a host name the user typed" },
|
||||
{ file: 'lib/plugin-sandbox/connect-dialog.ts', why: "adds the scheme to a server address the user enters" },
|
||||
{ file: 'lib/plugin-sandbox/host-api.ts', why: "WKD lookup at the recipient's domain (registered as wkd), and an error message" },
|
||||
{ file: 'lib/validation.ts', why: "scheme checks" },
|
||||
{ file: 'lib/vcard.ts', why: "scheme checks of contact URLs" },
|
||||
{ file: 'stores/calendar-store.ts', why: "webcal:// rewritten to https:// for a feed the user subscribed to" },
|
||||
];
|
||||
|
||||
/** Folders whose host literals are sample mail content, never requests. */
|
||||
export const NON_REQUEST_PATHS: readonly string[] = [
|
||||
'lib/demo/fixtures/',
|
||||
];
|
||||
|
||||
const RESERVED_TLDS = new Set(['example', 'test', 'invalid', 'localhost', 'local', 'internal', 'lan']);
|
||||
|
||||
/** IPv4 literals in private, loopback, link-local, shared, documentation and other reserved ranges. */
|
||||
export function isReservedIPv4(ip: string): boolean {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
|
||||
const [a, b, c] = parts;
|
||||
return a === 0 || a === 10 || a === 127 || a >= 224
|
||||
|| (a === 100 && b >= 64 && b <= 127)
|
||||
|| (a === 169 && b === 254)
|
||||
|| (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 168)
|
||||
|| (a === 192 && b === 0 && (c === 0 || c === 2))
|
||||
|| (a === 198 && (b === 18 || b === 19))
|
||||
|| (a === 198 && b === 51 && c === 100)
|
||||
|| (a === 203 && b === 0 && c === 113);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that can't be anyone else's: example and reserved names, single
|
||||
* labels, private or reserved IP literals and CSP wildcards. Placeholders and
|
||||
* tests use them. A public IP literal is not one.
|
||||
*/
|
||||
export function isPlaceholderHost(raw: string): boolean {
|
||||
const host = raw.toLowerCase().replace(/:\d+$/, '').replace(/\.+$/, '');
|
||||
if (/^[\d.]+$/.test(host)) return isReservedIPv4(host);
|
||||
if (!host.includes('.') || host.includes('*')) 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, with the files that may name it. */
|
||||
export function registeredLiterals(): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
for (const entry of OUTGOING_HOSTS) {
|
||||
for (const literal of entry.literals ?? [entry.host]) {
|
||||
const files = out.get(literal) ?? new Set<string>();
|
||||
for (const file of entry.files) files.add(file);
|
||||
out.set(literal, files);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The exact host names of the registry, which the browser module repeats. */
|
||||
export function registryHostnames(): string[] {
|
||||
return OUTGOING_HOSTS.flatMap((entry) => entry.literals ?? [entry.host]).filter((h) => !h.endsWith('.'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SettingsPolicy } from './types';
|
||||
import { parseJmapServers } from './jmap-servers';
|
||||
import { isBuiltInHost } from '@/lib/outgoing-hosts';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
|
||||
/**
|
||||
* What stays reachable with "no third-party traffic" on: the hosts the
|
||||
* deployment configured itself, plus the addresses users type in themselves.
|
||||
* Shown on Organisation > Security so an admin can check the list against
|
||||
* what they expect, and turned into the page's CSP in the mode (#903).
|
||||
*/
|
||||
|
||||
export interface ReachableHost {
|
||||
/** What the host is for, e.g. "JMAP server". */
|
||||
kind: string;
|
||||
/** The host (and port when it isn't the default), or "any address your users enter". */
|
||||
host: string;
|
||||
/** scheme://host[:port], for the CSP; absent for addresses users enter. */
|
||||
origin?: string;
|
||||
/** Where it was configured, e.g. "JMAP_SERVER_URL" or "User policy > Push relays". */
|
||||
setIn: string;
|
||||
/** Who connects: this server, the browser, or both. */
|
||||
side: 'server' | 'browser' | 'both';
|
||||
/** Something the admin should know about this line. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const USER_ENTERED = 'any address your users enter';
|
||||
|
||||
function parse(url: unknown): URL | null {
|
||||
if (typeof url !== 'string' || !url.trim()) return null;
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReachableInput {
|
||||
get: (key: string) => unknown;
|
||||
policy: Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'> & { features?: Partial<SettingsPolicy['features']> };
|
||||
env: Record<string, string | undefined>;
|
||||
/** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */
|
||||
buildRelayUrl?: string;
|
||||
}
|
||||
|
||||
const BRANDING_KEYS = [
|
||||
'faviconUrl', 'pwaIconUrl', 'pwaScreenshotMobileUrl', 'pwaScreenshotDesktopUrl',
|
||||
'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl',
|
||||
] as const;
|
||||
|
||||
export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_RELAY_BASE_URL }: ReachableInput): ReachableHost[] {
|
||||
const out: ReachableHost[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (kind: string, url: unknown, setIn: string, side: ReachableHost['side'], note?: string) => {
|
||||
const parsed = parse(url);
|
||||
// A built-in host listed here would be off in the mode anyway; leaving
|
||||
// it out keeps the list to what really stays reachable.
|
||||
if (!parsed || isBuiltInHost(parsed.href)) return;
|
||||
const host = parsed.host.toLowerCase();
|
||||
const key = `${kind}|${host}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ kind, host, origin: parsed.origin, setIn, side, ...(note ? { note } : {}) });
|
||||
};
|
||||
|
||||
add('JMAP server', get('jmapServerUrl'), 'JMAP_SERVER_URL', 'both');
|
||||
for (const server of parseJmapServers(get('jmapServers'))) {
|
||||
add('JMAP server', server.url, `JMAP_SERVERS (${server.label})`, 'both');
|
||||
add('OAuth issuer', server.oauth?.issuerUrl, `JMAP_SERVERS (${server.label})`, 'both');
|
||||
add('OAuth issuer', server.oauth?.endSessionUrl, `JMAP_SERVERS (${server.label})`, 'browser');
|
||||
}
|
||||
if (get('oauthEnabled') === true) {
|
||||
add('OAuth issuer', get('oauthIssuerUrl'), 'OAUTH_ISSUER_URL', 'both');
|
||||
add('OAuth issuer', get('oauthAuthorizeUrl'), 'OAUTH_AUTHORIZE_URL', 'browser');
|
||||
add('OAuth issuer', get('oauthEndSessionUrl'), 'OAUTH_END_SESSION_URL', 'browser');
|
||||
}
|
||||
for (const relay of policy.pushRelays ?? []) add('Push relay', relay?.url, 'User policy > Push relays', 'browser');
|
||||
add('Push relay', policy.pushRelayUrl, 'User policy > Push relays', 'browser');
|
||||
// The relay browsers offer as the default is the one inlined when the
|
||||
// bundle was built. A different value in the running environment does
|
||||
// nothing for them, so it is listed with a warning.
|
||||
add('Push relay', buildRelayUrl, 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', 'browser');
|
||||
const runtimeRelay = parse(env.NEXT_PUBLIC_PUSH_RELAY_URL);
|
||||
if (runtimeRelay && runtimeRelay.origin !== parse(buildRelayUrl)?.origin) {
|
||||
add('Push relay', runtimeRelay.href, 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', 'browser',
|
||||
'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under User policy > Push relays.');
|
||||
}
|
||||
add('Extension directory', get('extensionDirectoryUrl'), 'EXTENSION_DIRECTORY_URL', 'server');
|
||||
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
|
||||
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
|
||||
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
|
||||
for (const key of BRANDING_KEYS) add('Branding image', get(key), key, 'browser');
|
||||
|
||||
// User-directed: what users type in themselves stays allowed.
|
||||
if (get('allowCustomJmapEndpoint') === true) {
|
||||
out.push({ kind: 'JMAP server', host: USER_ENTERED, setIn: 'ALLOW_CUSTOM_JMAP_ENDPOINT', side: 'both',
|
||||
note: 'Users can sign in to any server, so the browser may connect to any https host.' });
|
||||
}
|
||||
if (policy.features?.sidebarAppsEnabled !== false) {
|
||||
out.push({ kind: 'Sidebar apps users add', host: USER_ENTERED, setIn: 'Users, in Settings > Apps', side: 'browser' });
|
||||
}
|
||||
if (policy.features?.calendarEnabled !== false) {
|
||||
out.push({ kind: 'Calendar subscriptions', host: USER_ENTERED, setIn: 'Users, in Calendar', side: 'server' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Content-Security-Policy sources of the page in the no-third-party mode:
|
||||
* 'self' and the origins above. With custom JMAP endpoints allowed, the
|
||||
* browser must reach whatever server a user signs in to, so connect-src
|
||||
* keeps https: (the other directives stay narrow).
|
||||
*/
|
||||
export function noThirdPartyCspSources(hosts: ReachableHost[], allowCustomJmapEndpoint: boolean): { connect: string[]; other: string[] } {
|
||||
const origins = [...new Set(hosts.flatMap((h) => (h.origin ? [h.origin] : [])))];
|
||||
return {
|
||||
connect: allowCustomJmapEndpoint ? [...origins, 'https:'] : origins,
|
||||
other: origins,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { configManager } from './config-manager';
|
||||
import { parseNoThirdPartyEnv, parseNoThirdPartyValue } 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;
|
||||
// config.json may hold "true", "1", "yes" or "on" as a string.
|
||||
const on = parseNoThirdPartyValue(entry?.value);
|
||||
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 parseNoThirdPartyValue(configManager.get<unknown>('noThirdPartyTraffic', false));
|
||||
}
|
||||
|
||||
/** 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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ConfigData } from '@/hooks/use-config';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { httpUrlOrEmpty } from '@/lib/config-validation';
|
||||
import { parseNoThirdPartyValue } from '@/lib/third-party-mode';
|
||||
import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy';
|
||||
import { sanitizeRestrictions, sanitizeScopes } from '@/lib/admin/policy-scopes';
|
||||
|
||||
@@ -95,6 +96,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: parseNoThirdPartyValue(input.noThirdPartyTraffic),
|
||||
...LITE_FORCED_FLAGS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The browser-side switches of the "no third-party traffic" mode (#903).
|
||||
*
|
||||
* Small on purpose: this module ships in every client chunk and in Lite. The
|
||||
* registry with the admin-facing descriptions is lib/admin/outgoing-hosts-registry.ts.
|
||||
* Every helper that opens an outside host asks noThirdPartyTraffic(), which is
|
||||
* true while the mode is on and while the config isn't known yet.
|
||||
*/
|
||||
|
||||
import { noThirdPartyTraffic } from './third-party-mode';
|
||||
|
||||
/** Exact host names of the built-in outside hosts (the registry repeats them; a test keeps both equal). */
|
||||
export const BUILT_IN_HOSTNAMES: readonly string[] = [
|
||||
'telemetry.bulwarkmail.org',
|
||||
'version.telemetry.bulwarkmail.org',
|
||||
'notifications.relay.bulwarkmail.org',
|
||||
'extensions.bulwarkmail.org',
|
||||
'connector.bulwarkmail.org',
|
||||
'icons.duckduckgo.com',
|
||||
'api.mymemory.translated.net',
|
||||
'www.google.com',
|
||||
'randomuser.me',
|
||||
'img.freepik.com',
|
||||
];
|
||||
|
||||
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. */
|
||||
export function isBuiltInHost(url: string): boolean {
|
||||
const host = hostnameOf(url);
|
||||
return !!host && BUILT_IN_HOSTNAMES.includes(host);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* An avatar URL may be shown: a plugin's (Gravatar, Libravatar...) or a
|
||||
* contact card's photo link. In the mode only when it stays on this origin or
|
||||
* is inline data, so those avatars fall back to initials instead of reaching
|
||||
* out (or tripping the CSP).
|
||||
*/
|
||||
export function avatarUrlAllowed(url: string | null | undefined, origin?: string): boolean {
|
||||
if (!url) return false;
|
||||
if (!noThirdPartyTraffic()) return true;
|
||||
if (/^(data|blob):/i.test(url)) return true;
|
||||
const base = origin ?? (typeof window !== 'undefined' ? window.location.origin : '');
|
||||
try {
|
||||
return !!base && new URL(url, base).origin === new URL(base).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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('@');
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { cancelPluginProxyRequests } from './proxy-client';
|
||||
import { cancelConnectFor } from './connect-dialog';
|
||||
import { isHookHandedOver } from './host-features';
|
||||
import type { SlotName } from '../plugin-types';
|
||||
import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts';
|
||||
|
||||
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
||||
|
||||
@@ -223,6 +224,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,52 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Fail closed: until the config has been read the state is "unknown", and
|
||||
* every blocking helper treats "unknown" like "on". Only a config that says
|
||||
* off opens the outside hosts.
|
||||
*/
|
||||
|
||||
export type ThirdPartyModeState = 'unknown' | 'on' | 'off';
|
||||
|
||||
let state: ThirdPartyModeState = 'unknown';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** 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());
|
||||
}
|
||||
|
||||
/** A config value (boolean, or a string as in env) as on or off. */
|
||||
export function parseNoThirdPartyValue(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return typeof value === 'string' && parseNoThirdPartyEnv(value);
|
||||
}
|
||||
|
||||
/** Called once the runtime config is known; 'unknown' goes back to the start state. */
|
||||
export function setNoThirdPartyTraffic(on: boolean | 'unknown'): void {
|
||||
const next: ThirdPartyModeState = on === 'unknown' ? 'unknown' : on ? 'on' : 'off';
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function thirdPartyModeState(): ThirdPartyModeState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Whether the browser must stay off every built-in outside host: on, or not known yet. */
|
||||
export function noThirdPartyTraffic(): boolean {
|
||||
return state !== 'off';
|
||||
}
|
||||
|
||||
/** For useSyncExternalStore (hooks/use-third-party-mode.ts). */
|
||||
export function subscribeThirdPartyMode(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -65,7 +70,10 @@ async function scheduleNext(delayMs: number): Promise<void> {
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const result = await checkOnce({ reason: 'scheduled' });
|
||||
const delay = result.ok ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
|
||||
// The scheduler keeps running while third-party traffic is off, so turning
|
||||
// the switch off at runtime resumes the checks; checkOnce skips meanwhile.
|
||||
const skipped = result.error === NO_THIRD_PARTY_ERROR;
|
||||
const delay = result.ok || skipped ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
|
||||
await scheduleNext(delay);
|
||||
}
|
||||
|
||||
|
||||
+39
-3
@@ -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 { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts';
|
||||
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import {
|
||||
EMPTY_NOTIFICATION_MUTES,
|
||||
@@ -37,6 +39,16 @@ function subscriptionIdKey(accountId: string): string {
|
||||
return SUBSCRIPTION_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay base a registration was made with. The server's PushSubscription
|
||||
* points at it, so a later change of relay (the admin's list, or the built-in
|
||||
* one going away with no third-party traffic, #903) needs a new record.
|
||||
*/
|
||||
const RELAY_BASE_PREFIX = 'bulwark.push.relayBase.v1.';
|
||||
function relayBaseKey(accountId: string): string {
|
||||
return RELAY_BASE_PREFIX + accountId;
|
||||
}
|
||||
|
||||
function emailPushRefusedKey(accountId: string): string {
|
||||
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
|
||||
}
|
||||
@@ -433,7 +445,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;
|
||||
}
|
||||
|
||||
@@ -847,6 +861,7 @@ export async function enableWebPush(
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
localStorage.setItem(subIdKey, serverAssignedId);
|
||||
localStorage.setItem(relayBaseKey(accountId), relayBaseUrl);
|
||||
if (replacedServerId) {
|
||||
await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined);
|
||||
}
|
||||
@@ -890,10 +905,11 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
||||
await params.client.destroyPushSubscription(id).catch(() => undefined);
|
||||
}
|
||||
localStorage.removeItem(subIdKey);
|
||||
localStorage.removeItem(relayBaseKey(accountId));
|
||||
// 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 +996,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,9 +1087,29 @@ 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;
|
||||
if (!params.force && !(await resyncDue(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;
|
||||
}
|
||||
// The registration points at another relay than the one to use now (the
|
||||
// admin changed the list, or the built-in relay went away with no
|
||||
// third-party traffic): make a new server record that points here. A
|
||||
// registration from before the relay was remembered counts as the
|
||||
// built-in one in that mode.
|
||||
const current = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
const registeredWith = localStorage.getItem(relayBaseKey(accountId));
|
||||
const moved = !!current && (
|
||||
(registeredWith !== null && registeredWith !== current)
|
||||
|| (noThirdPartyTraffic() && (registeredWith === null || isBuiltInHost(registeredWith)) && !isBuiltInHost(current))
|
||||
);
|
||||
if (!moved && !params.force && !(await resyncDue(accountId))) return false;
|
||||
await enableWebPush({
|
||||
client: params.client,
|
||||
forceRecreate: moved,
|
||||
relayBaseUrl: params.relayBaseUrl,
|
||||
accountLabel: params.accountLabel,
|
||||
inboxOnly: params.inboxOnly,
|
||||
|
||||
@@ -1007,6 +1007,13 @@
|
||||
"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.",
|
||||
"remote_content_blocked": "Ihre Organisation blockiert Inhalte von anderen Servern, daher werden externe Bilder in dieser Nachricht nicht angezeigt."
|
||||
},
|
||||
"date": {
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
|
||||
@@ -1007,6 +1007,13 @@
|
||||
"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.",
|
||||
"remote_content_blocked": "Your organisation blocks content from other servers, so remote pictures in this message aren't shown."
|
||||
},
|
||||
"date": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
pickAppFrameOriginsCookie,
|
||||
} from "./lib/security/app-frame-origins";
|
||||
import { configManager } from "./lib/admin/config-manager";
|
||||
import { isNoThirdPartyTrafficOn } from "./lib/admin/third-party-mode";
|
||||
import { listReachableHosts, noThirdPartyCspSources } from "./lib/admin/reachable-hosts";
|
||||
import { getWopiEditorOrigins } from "./lib/wopi/discovery";
|
||||
import { detectSetupState } from "./lib/setup/state";
|
||||
import { isServedOverHttps } from "./lib/security/request-protocol";
|
||||
@@ -298,11 +300,29 @@ export async function proxy(request: NextRequest) {
|
||||
// itself gets its own origin and nothing else, so fetch(), images, fonts
|
||||
// and CSS url() can't carry data to a host the manifest didn't name. Its
|
||||
// own origin stays because the router refetches the page as RSC.
|
||||
//
|
||||
// With no third-party traffic (#903) the page itself gets the same kind of
|
||||
// fence: its own origin plus the hosts the deployment configured (JMAP
|
||||
// servers, OAuth issuer, relays, a self-hosted directory, sidebar apps,
|
||||
// WOPI, LibreTranslate, branding images). It catches whatever the in-app
|
||||
// switches miss, privileged plugins and plugin avatar URLs included.
|
||||
const noThirdParty = !isSandboxPath && await isNoThirdPartyTrafficOn();
|
||||
const ownHosts = noThirdParty
|
||||
? noThirdPartyCspSources(
|
||||
listReachableHosts({ get: (key) => configManager.get(key), policy: configManager.getPolicy(), env: process.env }),
|
||||
configManager.get<boolean>("allowCustomJmapEndpoint", false) === true,
|
||||
)
|
||||
: null;
|
||||
const own = (sources: string[]) => (sources.length > 0 ? ` ${sources.join(" ")}` : "");
|
||||
const connectSrc = isSandboxPath
|
||||
? `'self'`
|
||||
: ownHosts ? `'self'${own(ownHosts.connect)}`
|
||||
: isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
|
||||
const imgSrc = isSandboxPath ? `'self' data: blob:` : `'self' data: blob: https:`;
|
||||
const fontSrc = isSandboxPath ? `'self' data:` : `'self' https: data:`;
|
||||
const imgSrc = isSandboxPath ? `'self' data: blob:`
|
||||
: ownHosts ? `'self' data: blob:${own(ownHosts.other)}` : `'self' data: blob: https:`;
|
||||
const fontSrc = isSandboxPath ? `'self' data:`
|
||||
: ownHosts ? `'self' data:${own(ownHosts.other)}` : `'self' https: data:`;
|
||||
const mediaSrc = ownHosts ? `'self' blob:${own(ownHosts.other)}` : `'self' blob:`;
|
||||
|
||||
// The admin dashboard and the setup wizard are never framed. The embedding
|
||||
// allowance is for the mail UI a portal wraps; a framed admin page is a
|
||||
@@ -349,7 +369,7 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
const frameOrigins: string[] = [];
|
||||
const seenFrameOrigins = new Set<string>();
|
||||
for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...wopiOrigins]) {
|
||||
for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...wopiOrigins, ...(ownHosts?.other ?? [])]) {
|
||||
const key = origin.toLowerCase();
|
||||
if (seenFrameOrigins.has(key)) continue;
|
||||
seenFrameOrigins.add(key);
|
||||
@@ -375,7 +395,7 @@ export async function proxy(request: NextRequest) {
|
||||
// form must not post anywhere: that would be a way out past connect-src.
|
||||
isSandboxPath ? `form-action 'none'` : `form-action ${["'self'", ...wopiOrigins].join(" ")}`,
|
||||
`frame-ancestors ${frameAncestors}`,
|
||||
`media-src 'self' blob:`,
|
||||
`media-src ${mediaSrc}`,
|
||||
].join("; ");
|
||||
|
||||
// Skip intl middleware for routes outside the localized app tree.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+42
-11
@@ -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,
|
||||
};
|
||||
@@ -361,7 +363,12 @@ export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACE
|
||||
}
|
||||
|
||||
/** The Content-Security-Policy every static host should send (`_headers`, the container image). */
|
||||
export function buildCsp({ connectSrc = "*" } = {}) {
|
||||
export function buildCsp({ connectSrc = "*", noThirdParty = false } = {}) {
|
||||
if (noThirdParty) {
|
||||
const strict = noThirdPartyDirectives(connectSrc);
|
||||
const byName = new Map(strict.map((d) => [d.split(" ")[0], d]));
|
||||
return buildCsp({ connectSrc }).split("; ").map((d) => byName.get(d.split(" ")[0]) ?? d).join("; ");
|
||||
}
|
||||
return [
|
||||
"default-src 'self'",
|
||||
// The static export ships inline hydration scripts, so no nonce is possible
|
||||
@@ -405,8 +412,30 @@ export function inlineScriptHashes(html) {
|
||||
* and scripts from this origin, nothing else. Enforced together with the
|
||||
* host's header policy, it takes away the header's 'unsafe-inline'.
|
||||
*/
|
||||
export function buildScriptHashCsp(hashes) {
|
||||
return [`script-src 'self'${hashes.map((h) => ` ${h}`).join("")}`, "object-src 'none'", "base-uri 'self'"].join("; ");
|
||||
export function buildScriptHashCsp(hashes, { noThirdParty = false, connectSrc = "*" } = {}) {
|
||||
return [
|
||||
`script-src 'self'${hashes.map((h) => ` ${h}`).join("")}`,
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
...(noThirdParty ? noThirdPartyDirectives(connectSrc) : []),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* A build made with LITE_NO_THIRD_PARTY_TRAFFIC=true (#903) fences the page
|
||||
* like the server build's header does: its own origin and the JMAP server
|
||||
* only, so a link the in-app switches miss still can't load anything from
|
||||
* an outside host. `connectSrc` is the JMAP origin, or `*` when users may
|
||||
* type any server (then only connect-src stays open).
|
||||
*/
|
||||
export function noThirdPartyDirectives(connectSrc = "*") {
|
||||
const own = connectSrc === "*" ? "" : ` ${connectSrc}`;
|
||||
return [
|
||||
`connect-src 'self' ${connectSrc}`,
|
||||
`img-src 'self' data: blob:${own}`,
|
||||
"font-src 'self' data:",
|
||||
"media-src 'self' blob:",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,8 +443,8 @@ export function buildScriptHashCsp(hashes) {
|
||||
* script: right after <meta charset> (which has to stay within the first
|
||||
* 1024 bytes), else as early in the document as possible.
|
||||
*/
|
||||
export function withScriptHashCsp(html) {
|
||||
const meta = `<meta http-equiv="Content-Security-Policy" content="${buildScriptHashCsp(inlineScriptHashes(html))}"/>`;
|
||||
export function withScriptHashCsp(html, options = {}) {
|
||||
const meta = `<meta http-equiv="Content-Security-Policy" content="${buildScriptHashCsp(inlineScriptHashes(html), options)}"/>`;
|
||||
// Without a <head> the parser still places a leading <meta> in the implied one.
|
||||
const anchor = /<meta\s+charset\s*=[^>]*>/i.exec(html) ?? /<head\b[^>]*>/i.exec(html)
|
||||
?? /<html\b[^>]*>/i.exec(html) ?? /<!doctype[^>]*>/i.exec(html);
|
||||
@@ -439,8 +468,8 @@ export function buildStalwartEntryCsp() {
|
||||
}
|
||||
|
||||
/** Security headers for hosts that read a `_headers` file. */
|
||||
export function buildHeaders({ basePath = "", connectSrc = "*" }) {
|
||||
const csp = buildCsp({ connectSrc });
|
||||
export function buildHeaders({ basePath = "", connectSrc = "*", noThirdParty = false }) {
|
||||
const csp = buildCsp({ connectSrc, noThirdParty });
|
||||
return `${basePath || ""}/*
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
@@ -465,9 +494,9 @@ export function exampleDocRoot(basePath = "") {
|
||||
return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` };
|
||||
}
|
||||
|
||||
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*" }) {
|
||||
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*", noThirdParty = false }) {
|
||||
const location = basePath ? `${basePath}/` : "/";
|
||||
const csp = buildCsp({ connectSrc });
|
||||
const csp = buildCsp({ connectSrc, noThirdParty });
|
||||
const { root, files } = exampleDocRoot(basePath);
|
||||
const surfaceAlternation = surfaces.join("|");
|
||||
return `# Bulwark Lite - nginx example. Unzip the archive into ${files}
|
||||
@@ -596,9 +625,9 @@ ${securityHeaders(" ")}
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*" }) {
|
||||
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*", noThirdParty = false }) {
|
||||
const { root, files } = exampleDocRoot(basePath);
|
||||
const csp = buildCsp({ connectSrc });
|
||||
const csp = buildCsp({ connectSrc, noThirdParty });
|
||||
const surfaceAlternation = surfaces.join("|");
|
||||
return `# Bulwark Lite - Caddy example. Unzip the archive into ${files}
|
||||
# so that ${basePath}/index.html is served at ${basePath || ""}/.
|
||||
@@ -661,6 +690,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
|
||||
|
||||
@@ -51,7 +51,13 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
|
||||
// The Stalwart target rewrites its shells at runtime, so their scripts
|
||||
// cannot be hashed at build time (see buildStalwartEntryCsp).
|
||||
if (target === "static") {
|
||||
const pages = applyScriptHashCsp(outDir);
|
||||
// With the switch on at build time the page policy also fences off
|
||||
// outside hosts (#903). config.json can still be edited afterwards; a
|
||||
// switch turned on there relies on the in-app checks and the web
|
||||
// server's own policy (see the third-party traffic docs).
|
||||
const pages = applyScriptHashCsp(outDir, config.noThirdPartyTraffic
|
||||
? { noThirdParty: true, connectSrc: liteConnectSrc(config) }
|
||||
: {});
|
||||
log(`[lite] pinned the inline scripts of ${pages} pages with a <meta> CSP`);
|
||||
}
|
||||
log(`[lite] wrote ${Object.keys(files).length} files into out/ (${target} target, ${locales.length} locales, base path ${target === "stalwart" ? "runtime" : basePath || "/"})`);
|
||||
@@ -66,8 +72,13 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
|
||||
return { outDir, locales, basePath, target, buildId, zipPath, files: Object.keys(files) };
|
||||
}
|
||||
|
||||
function liteConnectSrc(config) {
|
||||
return config.allowCustomJmapEndpoint || !config.jmapServerUrl ? "*" : new URL(config.jmapServerUrl).origin;
|
||||
}
|
||||
|
||||
function staticFiles({ config, basePath, locales, defaultLocale, version, commit }) {
|
||||
const connectSrc = config.allowCustomJmapEndpoint || !config.jmapServerUrl ? "*" : new URL(config.jmapServerUrl).origin;
|
||||
const connectSrc = liteConnectSrc(config);
|
||||
const noThirdParty = config.noThirdPartyTraffic === true;
|
||||
return {
|
||||
"config.json": JSON.stringify(config, null, 2) + "\n",
|
||||
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
|
||||
@@ -80,9 +91,9 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit
|
||||
// on hosts without rewrite rules (see buildNotFoundShim).
|
||||
"404.html": buildNotFoundShim({ basePath, locales }),
|
||||
"_redirects": buildRedirects({ basePath, locales }),
|
||||
"_headers": buildHeaders({ basePath, connectSrc }),
|
||||
"nginx.conf.example": buildNginxExample({ basePath, connectSrc }),
|
||||
"Caddyfile.example": buildCaddyExample({ basePath, connectSrc }),
|
||||
"_headers": buildHeaders({ basePath, connectSrc, noThirdParty }),
|
||||
"nginx.conf.example": buildNginxExample({ basePath, connectSrc, noThirdParty }),
|
||||
"Caddyfile.example": buildCaddyExample({ basePath, connectSrc, noThirdParty }),
|
||||
"LITE-README.md": buildReadme({ version, commit, basePath, locales, jmapServerUrl: config.jmapServerUrl, demoMode: config.demoMode }),
|
||||
};
|
||||
}
|
||||
@@ -111,7 +122,7 @@ function stalwartFiles({ outDir, config, locales, defaultLocale, version, commit
|
||||
}
|
||||
|
||||
/** Add each HTML file's script-hash policy (withScriptHashCsp); returns how many. */
|
||||
export function applyScriptHashCsp(outDir) {
|
||||
export function applyScriptHashCsp(outDir, options = {}) {
|
||||
let pages = 0;
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
@@ -119,7 +130,7 @@ export function applyScriptHashCsp(outDir) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "_next") walk(path);
|
||||
} else if (entry.name.endsWith(".html")) {
|
||||
writeFileSync(path, withScriptHashCsp(readFileSync(path, "utf8")));
|
||||
writeFileSync(path, withScriptHashCsp(readFileSync(path, "utf8"), options));
|
||||
pages++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// Scans a finished Lite export for outside hosts (#903). Every http(s) or
|
||||
// ws(s) host literal, and every protocol-relative //host in a string, must be
|
||||
// the configured JMAP server, a placeholder, one of Bulwark's built-in outside
|
||||
// hosts (switched off at runtime by noThirdPartyTraffic) or a known string
|
||||
// that is never requested, in the kind of file it is listed for. Anything else
|
||||
// fails the build in CI (.github/workflows/static-lite.yml).
|
||||
//
|
||||
// node scripts/lite/scan-hosts.mjs [outDir=out]
|
||||
//
|
||||
// LITE_BUILT_IN_HOSTS mirrors BUILT_IN_HOSTNAMES in lib/outgoing-hosts.ts (a
|
||||
// TS module this script cannot import); lib/__tests__/lite-bundle-hosts.test.ts
|
||||
// pins the two together.
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { isMainModule, resolveRepoRoot } from "./lib.mjs";
|
||||
|
||||
export const LITE_BUILT_IN_HOSTS = [
|
||||
"telemetry.bulwarkmail.org",
|
||||
"version.telemetry.bulwarkmail.org",
|
||||
"notifications.relay.bulwarkmail.org",
|
||||
"extensions.bulwarkmail.org",
|
||||
"connector.bulwarkmail.org",
|
||||
"icons.duckduckgo.com",
|
||||
"api.mymemory.translated.net",
|
||||
"www.google.com",
|
||||
"randomuser.me",
|
||||
"img.freepik.com",
|
||||
"openpgpkey.",
|
||||
];
|
||||
|
||||
const CHUNKS = /^_next\/static\/chunks\/[^/]+\.js$/;
|
||||
const PAGES = /\.(html|txt)$/;
|
||||
|
||||
/** Strings in the bundle that are never requested, by where they may appear and why. */
|
||||
export const LITE_BUNDLE_NON_REQUEST = [
|
||||
{ files: CHUNKS, why: "Bulwark's built-in hosts, off at runtime with noThirdPartyTraffic", hosts: LITE_BUILT_IN_HOSTS },
|
||||
{ files: CHUNKS, why: "error and docs links of bundled libraries (Next.js, React, JSZip, ProseMirror, core-js, date-fns)", hosts: ["nextjs.org", "react.dev", "stuk.github.io", "prosemirror.net", "github.com", "foo.bar"] },
|
||||
{ files: CHUNKS, why: "example addresses the translations localise", hosts: ["eksempel.dk", "mail.eksempel.dk", "piemers.lv", "pasts.piemers.lv", "ornek.com", "mail.ornek.com"] },
|
||||
{ files: CHUNKS, why: "links inside the sample mail of the demo mode (lib/demo/fixtures)", hosts: ["zoom.us", "notion.so", "www.figma.com", "vercel.com", "i.scdn.co", "cf.bstatic.com", "open.spotify.com", "linear.app", "sentry.io", "app.docusign.com", "github.com", "bulwark-webmail.vercel.app"] },
|
||||
{ files: CHUNKS, why: "JMAP capability URI and XML namespaces", hosts: ["bulwarkmail.com", "www.w3.org", "creativecommons.org"] },
|
||||
{ files: /./, why: "docs links in the translations and the README", hosts: ["bulwarkmail.org", "github.com"] },
|
||||
{ files: PAGES, why: "XML namespaces of inline SVG", hosts: ["www.w3.org"] },
|
||||
{ files: /^_next\/static\/media\/pdf\.worker[^/]*\.m?js$/, why: "XML namespaces and the licence link inside pdf.js", hosts: ["www.apache.org", "www.w3.org", "www.xfa.org", "ns.adobe.com"] },
|
||||
];
|
||||
|
||||
const RESERVED_TLDS = new Set(["example", "test", "invalid", "localhost", "local", "internal", "lan"]);
|
||||
|
||||
function isReservedIPv4(ip) {
|
||||
const parts = ip.split(".").map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
|
||||
const [a, b, c] = parts;
|
||||
return a === 0 || a === 10 || a === 127 || a >= 224
|
||||
|| (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
||||
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
||||
|| (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 198 && (b === 18 || b === 19))
|
||||
|| (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113);
|
||||
}
|
||||
|
||||
/** Same rule as isPlaceholderHost in lib/admin/outgoing-hosts-registry.ts. */
|
||||
export function isPlaceholderHost(raw) {
|
||||
const host = raw.toLowerCase().replace(/:\d+$/, "").replace(/\.+$/, "");
|
||||
if (/^[\d.]+$/.test(host)) return isReservedIPv4(host);
|
||||
if (!host.includes(".") || host.includes("*")) return true;
|
||||
const tld = host.split(".").pop();
|
||||
if (RESERVED_TLDS.has(tld)) return true;
|
||||
return /(^|\.)example\.(com|org|net)$/.test(host);
|
||||
}
|
||||
|
||||
const SCHEME_HOST = /\b(?:https?|wss?):\/\/([a-z0-9.*_-]+)/gi;
|
||||
const PROTOCOL_RELATIVE = /["'`]\/\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
||||
|
||||
export function bundleHosts(text) {
|
||||
const out = [];
|
||||
for (const m of text.matchAll(SCHEME_HOST)) out.push(m[1].toLowerCase());
|
||||
for (const m of text.matchAll(PROTOCOL_RELATIVE)) out.push(m[1].toLowerCase());
|
||||
return out;
|
||||
}
|
||||
|
||||
function files(dir, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) files(full, out);
|
||||
else if (/\.(js|mjs|html|css|json|txt|webmanifest|md|example)$/.test(name) || name === "_headers") out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "file: host" for every host in `outDir` that isn't accounted for. */
|
||||
export function scanBundleHosts(outDir) {
|
||||
let jmapHost = null;
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(join(outDir, "config.json"), "utf8"));
|
||||
if (config.jmapServerUrl) jmapHost = new URL(config.jmapServerUrl).host.toLowerCase();
|
||||
} catch { /* no config: nothing configured */ }
|
||||
const problems = new Set();
|
||||
for (const full of files(outDir)) {
|
||||
const file = relative(outDir, full).split(sep).join("/");
|
||||
for (const raw of bundleHosts(readFileSync(full, "utf8"))) {
|
||||
const host = raw.replace(/\.{2,}$/, "");
|
||||
if (host === jmapHost) continue;
|
||||
const listed = LITE_BUNDLE_NON_REQUEST.some((entry) => entry.files.test(file) && (entry.hosts.includes(raw) || entry.hosts.includes(host)));
|
||||
if (listed) continue;
|
||||
const known = LITE_BUNDLE_NON_REQUEST.some((entry) => entry.hosts.includes(host));
|
||||
if (!known && isPlaceholderHost(host)) continue;
|
||||
problems.add(`${file}: ${host}`);
|
||||
}
|
||||
}
|
||||
return [...problems];
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const outDir = process.argv[2] ?? join(resolveRepoRoot(import.meta.url), "out");
|
||||
const problems = scanBundleHosts(outDir);
|
||||
if (problems.length > 0) {
|
||||
console.error(`[lite] ${problems.length} outside host(s) in the bundle that aren't accounted for (scripts/lite/scan-hosts.mjs):`);
|
||||
for (const p of problems) console.error(` - ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("[lite] no unaccounted outside hosts in the bundle");
|
||||
}
|
||||
+9
-1
@@ -1,6 +1,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, vi } from 'vitest';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
|
||||
// jsdom does not implement matchMedia; components that read media queries
|
||||
// (e.g. responsive layout hooks) call it during render. Provide a minimal
|
||||
@@ -67,6 +68,13 @@ vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// The browser treats the no-third-party mode as on until the config says
|
||||
// otherwise (#903). Tests start from a config that says off; the tests of
|
||||
// the mode set it themselves.
|
||||
beforeEach(() => {
|
||||
setNoThirdPartyTraffic(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user