mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
361 lines
17 KiB
TypeScript
361 lines
17 KiB
TypeScript
// @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;
|
|
}
|
|
});
|
|
});
|