diff --git a/lib/__tests__/outgoing-hosts-mode.test.ts b/lib/__tests__/outgoing-hosts-mode.test.ts new file mode 100644 index 000000000..be4a37094 --- /dev/null +++ b/lib/__tests__/outgoing-hosts-mode.test.ts @@ -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, + 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 Promise> = { + 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, h] as const))('%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, h] as const))('%s is off with the mode on', async (id) => { + await setMode(true); + expect(await probes[id]()).toBe(false); + }); +}); diff --git a/lib/__tests__/outgoing-hosts.test.ts b/lib/__tests__/outgoing-hosts.test.ts new file mode 100644 index 000000000..15342981e --- /dev/null +++ b/lib/__tests__/outgoing-hosts.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { + NON_REQUEST_HOSTS, + NON_REQUEST_PATHS, + OUTGOING_HOSTS, + isPlaceholderHost, + registeredLiterals, +} from '@/lib/outgoing-hosts'; + +// Every http(s) host literal in the shipped source must be a built-in +// outside host in lib/outgoing-hosts.ts (with a switch that turns it off) or +// a known non-request (docs link, namespace, sample text). 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']; +const FILES = ['public/sw.js', 'proxy.ts', 'instrumentation.ts', 'instrumentation.node.ts']; +const EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs)$/; +const HOST_LITERAL = /https?:\/\/([A-Za-z0-9.*_-]+)/g; + +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); + } +} + +export 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))); +} + +/** Host literals in a source text, as written (lower case, port dropped). */ +export function hostLiterals(text: string): string[] { + return [...text.matchAll(HOST_LITERAL)].map((m) => m[1].toLowerCase()); +} + +/** Why a host literal in `file` is fine, or null when it isn't accounted for. */ +export function classify(host: string, file: string, registered = registeredLiterals()): string | null { + if (registered.has(host)) return 'registry'; + const trimmed = host.replace(/\.+$/, ''); + if (registered.has(trimmed)) return 'registry'; + const allowed = NON_REQUEST_HOSTS.find((entry) => entry.host === trimmed); + if (allowed && allowed.files.includes(file)) return 'non-request'; + if (isPlaceholderHost(host)) return 'placeholder'; + return null; +} + +describe('outgoing host literals', () => { + const files = sourceFiles(); + + it('scans the shipped source, service worker included, and leaves the tests out', () => { + expect(files).toContain('public/sw.js'); + expect(files).toContain('app/api/favicon/route.ts'); + 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 that is neither registered nor a known non-request', () => { + const registered = registeredLiterals(); + const unknown: string[] = []; + for (const file of files) { + const text = readFileSync(path.join(ROOT, file), 'utf8'); + for (const host of hostLiterals(text)) { + if (!classify(host, file, registered)) unknown.push(`${file}: ${host}`); + } + } + expect(unknown, 'Add the host to OUTGOING_HOSTS with a switch, or to NON_REQUEST_HOSTS if Bulwark never connects to it').toEqual([]); + }); + + it('lists only hosts that are still in the source', () => { + const all = files.map((file) => ({ file, text: readFileSync(path.join(ROOT, file), 'utf8') })); + const literals = new Set(all.flatMap(({ text }) => hostLiterals(text))); + for (const entry of OUTGOING_HOSTS) { + for (const literal of entry.literals ?? [entry.host]) { + expect(literals.has(literal), `${entry.id}: ${literal}`).toBe(true); + } + } + for (const entry of NON_REQUEST_HOSTS) { + for (const file of entry.files) { + const text = all.find((f) => f.file === file)?.text ?? ''; + expect(hostLiterals(text).some((h) => h.replace(/\.+$/, '') === entry.host), `${entry.host} in ${file}`).toBe(true); + } + } + }); + + it('classifies the way the registry says', () => { + expect(classify('icons.duckduckgo.com', 'app/api/favicon/route.ts')).toBe('registry'); + expect(classify('openpgpkey.', 'lib/plugin-sandbox/host-api.ts')).toBe('registry'); + expect(classify('github.com', 'components/settings/about-data-settings.tsx')).toBe('non-request'); + expect(classify('github.com', 'lib/some-new-file.ts')).toBeNull(); + expect(classify('mail.example.com', 'anywhere.ts')).toBe('placeholder'); + expect(classify('cdn.unpkg.com', 'lib/x.ts')).toBeNull(); + }); +});