mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
238 lines
9.7 KiB
TypeScript
238 lines
9.7 KiB
TypeScript
// @vitest-environment node
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
|
|
// #1130: the webmail under /webmail behind a TLS-terminating reverse proxy,
|
|
// the editor reached internally for discovery and publicly by the browser.
|
|
// Next's standalone server then sees itself as http://0.0.0.0:3000, and the
|
|
// editor is served from an origin wopiClientUrl does not name.
|
|
|
|
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }));
|
|
|
|
vi.mock('@/lib/auth/session-secret', () => ({
|
|
getSessionSecret: () => 'x'.repeat(32),
|
|
hasSessionSecret: () => true,
|
|
}));
|
|
vi.mock('@/lib/logger', () => ({
|
|
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
|
|
}));
|
|
vi.mock('@/lib/admin/config-manager', () => ({
|
|
configManager: {
|
|
ensureLoaded: async () => {},
|
|
get: (key: string, fallback: unknown) => config.values[key] ?? fallback,
|
|
getPolicy: () => ({ features: { pluginsEnabled: false } }),
|
|
},
|
|
}));
|
|
vi.mock('@/lib/setup/state', () => ({ detectSetupState: () => 'configured' }));
|
|
vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: async () => [] }));
|
|
vi.mock('next-intl/middleware', async () => {
|
|
const { NextResponse } = await import('next/server');
|
|
return { default: () => () => NextResponse.next() };
|
|
});
|
|
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
|
vi.mock('next/headers', () => ({
|
|
cookies: async () => ({ get: () => undefined, set: () => {}, delete: () => {} }),
|
|
}));
|
|
vi.mock('@/lib/stalwart/server-fetch', () => ({
|
|
fetchJmapServer: vi.fn(),
|
|
isTrustedJmapServerUrl: async () => true,
|
|
}));
|
|
vi.mock('@/lib/stalwart/jmap-api', () => ({
|
|
fetchJmapSession: vi.fn(),
|
|
postJmap: vi.fn(),
|
|
rebaseApiUrl: () => null,
|
|
}));
|
|
|
|
import { proxy } from '@/proxy';
|
|
import { POST as launch } from '@/app/api/wopi/launch/route';
|
|
import { GET as checkFileInfo } from '@/app/api/wopi/files/[fileId]/route';
|
|
import { buildWopiActionUrl, getWopiEditorOrigins } from '@/lib/wopi/discovery';
|
|
import { wopiBrowserOrigin, wopiHostBase } from '@/lib/wopi/origin';
|
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
|
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
|
|
|
|
process.env.ADMIN_STATE_DIR = mkdtempSync(path.join(tmpdir(), 'bw-wopi-'));
|
|
|
|
const BIND = 'http://0.0.0.0:3000';
|
|
const PUBLIC = 'https://mail.example.com';
|
|
const DISCOVERY = `<wopi-discovery><net-zone name="external-http">
|
|
<app name="writer">
|
|
<action default="true" ext="docx" name="edit" urlsrc="https://office.example.com/browser/201368fc8d/cool.html?"/>
|
|
<action ext="pdf" name="view" urlsrc="https://office.example.com/browser/201368fc8d/cool.html?"/>
|
|
</app>
|
|
</net-zone></wopi-discovery>`;
|
|
|
|
let discoveryFetch: Mock;
|
|
let internalHost = 0;
|
|
|
|
function request(
|
|
pathname: string,
|
|
init: { body?: unknown; headers?: Record<string, string>; basePath?: string } = {},
|
|
) {
|
|
const nextUrl = Object.assign(new URL(pathname, BIND), { basePath: init.basePath ?? '' });
|
|
return {
|
|
nextUrl,
|
|
headers: new Headers(init.headers),
|
|
json: async () => init.body,
|
|
} as unknown as Parameters<typeof launch>[0];
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// A fresh discovery URL per test, so no test reads another one's cache.
|
|
internalHost += 1;
|
|
config.values = { wopiClientUrl: `http://10.0.0.${internalHost}:9980` };
|
|
discoveryFetch = vi.fn(async () => new Response(DISCOVERY, { status: 200 }));
|
|
vi.stubGlobal('fetch', discoveryFetch);
|
|
(getStalwartCredentials as Mock).mockResolvedValue({
|
|
serverUrl: 'https://stalwart.example.com',
|
|
authHeader: 'Basic dXNlcjpwYXNz',
|
|
username: 'user@example.com',
|
|
trusted: true,
|
|
slot: 0,
|
|
});
|
|
(fetchJmapSession as Mock).mockResolvedValue({
|
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' },
|
|
accounts: { c: {} },
|
|
downloadUrl: 'https://stalwart.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
|
|
});
|
|
(fetchJmapServer as Mock).mockImplementation(async () =>
|
|
new Response('DOCX-BYTES', { status: 200, headers: { 'Content-Length': '10' } }),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('editor origins in the page CSP', () => {
|
|
it('names the origin the browser loads the editor from, not only the discovery address', async () => {
|
|
expect(await getWopiEditorOrigins()).toEqual([
|
|
`http://10.0.0.${internalHost}:9980`,
|
|
'https://office.example.com',
|
|
]);
|
|
});
|
|
|
|
it('allows that origin as a frame and as a form target', async () => {
|
|
const response = await proxy(new NextRequest('http://localhost:3000/en/mail', {
|
|
headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' },
|
|
}));
|
|
const csp = response.headers.get('content-security-policy') ?? '';
|
|
expect(csp).toMatch(/frame-src 'self' blob: [^;]*https:\/\/office\.example\.com/);
|
|
expect(csp).toMatch(/form-action 'self' [^;]*https:\/\/office\.example\.com/);
|
|
});
|
|
|
|
it('fetches discovery once for many pages', async () => {
|
|
await Promise.all([getWopiEditorOrigins(), getWopiEditorOrigins()]);
|
|
await getWopiEditorOrigins();
|
|
expect(discoveryFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('falls back to the configured origin while the editor is down, without asking again per page', async () => {
|
|
discoveryFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
expect(await getWopiEditorOrigins()).toEqual([`http://10.0.0.${internalHost}:9980`]);
|
|
await getWopiEditorOrigins();
|
|
expect(discoveryFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('adds nothing when no editor is configured', async () => {
|
|
config.values = {};
|
|
expect(await getWopiEditorOrigins()).toEqual([]);
|
|
expect(discoveryFetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('wopiBrowserOrigin', () => {
|
|
it('takes the Origin of the launch request over the bind address', () => {
|
|
expect(wopiBrowserOrigin(request('/api/wopi/launch', { headers: { origin: PUBLIC } }))).toBe(PUBLIC);
|
|
});
|
|
|
|
it('falls back to what the reverse proxy forwarded, then to the request URL', () => {
|
|
expect(wopiBrowserOrigin(request('/api/wopi/launch', {
|
|
headers: { host: 'mail.example.com', 'x-forwarded-proto': 'https' },
|
|
}))).toBe(PUBLIC);
|
|
expect(wopiBrowserOrigin(request('/api/wopi/launch', { headers: { origin: 'null' } }))).toBe(BIND);
|
|
});
|
|
});
|
|
|
|
describe('wopiHostBase', () => {
|
|
const req = request('/api/wopi/launch', { headers: { origin: PUBLIC }, basePath: '/webmail' });
|
|
|
|
it('defaults to the public origin under the base path', () => {
|
|
expect(wopiHostBase(req, '')).toBe(`${PUBLIC}/webmail`);
|
|
});
|
|
|
|
it('adds the base path to an override that is a bare origin', () => {
|
|
expect(wopiHostBase(req, 'http://10.0.0.4')).toBe('http://10.0.0.4/webmail');
|
|
expect(wopiHostBase(req, 'http://10.0.0.4:3000/')).toBe('http://10.0.0.4:3000/webmail');
|
|
});
|
|
|
|
it('takes an override with a path as written', () => {
|
|
expect(wopiHostBase(req, 'http://10.0.0.4/webmail/')).toBe('http://10.0.0.4/webmail');
|
|
expect(wopiHostBase(req, 'http://gateway/mail')).toBe('http://gateway/mail');
|
|
});
|
|
});
|
|
|
|
describe('buildWopiActionUrl with a language', () => {
|
|
const SRC = 'http://h/api/wopi/files/f1';
|
|
|
|
it('passes lang to an editor whose discovery has no placeholder (Collabora)', () => {
|
|
expect(buildWopiActionUrl('https://office.example.com/browser/abc/cool.html?', SRC, 'fr')).toBe(
|
|
`https://office.example.com/browser/abc/cool.html?lang=fr&WOPISrc=${encodeURIComponent(SRC)}`,
|
|
);
|
|
});
|
|
|
|
it('fills the UI language placeholders and drops the others', () => {
|
|
expect(buildWopiActionUrl('https://o.example/edit?<ui=UI_LLCC&><rs=DC_LLCC&><showcomments=DISABLE_CHAT&>', SRC, 'zh-TW')).toBe(
|
|
`https://o.example/edit?ui=zh-TW&rs=zh-TW&WOPISrc=${encodeURIComponent(SRC)}`,
|
|
);
|
|
});
|
|
|
|
it('ignores a language that is not a tag', () => {
|
|
expect(buildWopiActionUrl('https://o.example/edit?', SRC, 'fr&WOPISrc=http://evil')).toBe(
|
|
`https://o.example/edit?WOPISrc=${encodeURIComponent(SRC)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('launch behind a reverse proxy', () => {
|
|
async function launched(wopiHostUrl?: string) {
|
|
if (wopiHostUrl) config.values.wopiHostUrl = wopiHostUrl;
|
|
const res = await launch(request('/api/wopi/launch', {
|
|
// nginx passes the public Host on; the CSRF gate compares Origin to it.
|
|
headers: { origin: PUBLIC, host: 'mail.example.com' },
|
|
basePath: '/webmail',
|
|
body: { blobId: 'Gblob1', name: 'Quote.docx', lang: 'fr' },
|
|
}));
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json();
|
|
const url = new URL(data.url);
|
|
return { url, wopiSrc: url.searchParams.get('WOPISrc')!, token: data.accessToken as string };
|
|
}
|
|
|
|
it('builds WOPISrc on the public origin and base path, in the webmail language', async () => {
|
|
const { url, wopiSrc } = await launched();
|
|
expect(wopiSrc).toMatch(/^https:\/\/mail\.example\.com\/webmail\/api\/wopi\/files\/[\w-]+$/);
|
|
expect(url.searchParams.get('lang')).toBe('fr');
|
|
});
|
|
|
|
it('adds the base path to WOPI_HOST_URL', async () => {
|
|
const { wopiSrc } = await launched('http://10.0.0.4');
|
|
expect(wopiSrc.startsWith('http://10.0.0.4/webmail/api/wopi/files/')).toBe(true);
|
|
});
|
|
|
|
it('reports the page origin as PostMessageOrigin', async () => {
|
|
const { wopiSrc, token } = await launched();
|
|
const documentId = wopiSrc.split('/').pop()!;
|
|
const res = await checkFileInfo(
|
|
request(`/api/wopi/files/${documentId}?access_token=${encodeURIComponent(token)}`),
|
|
{ params: Promise.resolve({ fileId: documentId }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect((await res.json()).PostMessageOrigin).toBe(PUBLIC);
|
|
});
|
|
});
|