mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
test: fail when a new outgoing host appears #903
This commit is contained in:
@@ -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<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, 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user