mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
fix: open the WOPI editor behind a reverse proxy with split addresses #1130
This commit is contained in:
@@ -163,10 +163,10 @@ export function SettingsTab() {
|
||||
|
||||
<SettingsSection title="Document Editing (WOPI)">
|
||||
<TextSetting label="WOPI Client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" />
|
||||
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="http://webmail:3000" />
|
||||
<div className="px-4 py-2.5">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks). The editor must also allow this webmail as a WOPI host.
|
||||
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail's base path is added to it. The editor must also allow this webmail as a WOPI host.
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function GET(
|
||||
SupportsUpdate: false,
|
||||
SupportsLocks: false,
|
||||
Version: payload.fileId,
|
||||
PostMessageOrigin: payload.origin,
|
||||
PostMessageOrigin: payload.postMessageOrigin || payload.origin,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function GET(
|
||||
SupportsLocks: false,
|
||||
LastModifiedTime: node.modified,
|
||||
Version: node.blobId,
|
||||
PostMessageOrigin: auth.payload.origin,
|
||||
PostMessageOrigin: auth.payload.postMessageOrigin || auth.payload.origin,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('WOPI CheckFileInfo failed', {
|
||||
|
||||
@@ -9,11 +9,13 @@ import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery';
|
||||
import { getWopiFileNode, probeBlob } from '@/lib/wopi/files';
|
||||
import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token';
|
||||
import { wopiBrowserBinding } from '@/lib/wopi/revocation';
|
||||
import { wopiBrowserOrigin, wopiHostBase } from '@/lib/wopi/origin';
|
||||
|
||||
/**
|
||||
* POST /api/wopi/launch
|
||||
* { fileId: string, accountId?: string } - a Files node
|
||||
* { blobId: string, name: string, type?: string, accountId?: string } - a mail attachment
|
||||
* plus an optional `lang`, the webmail's locale, for the editor's own UI.
|
||||
*
|
||||
* Mints a WOPI access token scoped to one document and returns the editor
|
||||
* URL to POST it to (#425). Attachments always open read-only (#1047).
|
||||
@@ -108,9 +110,7 @@ export async function POST(request: NextRequest) {
|
||||
// Where the editor reaches this webmail. Deployments where the editor
|
||||
// sees a different host than the browser (docker networks, split DNS)
|
||||
// override it via wopiHostUrl.
|
||||
const hostBase =
|
||||
configManager.get<string>('wopiHostUrl', '').trim().replace(/\/+$/, '') ||
|
||||
request.nextUrl.origin;
|
||||
const hostBase = wopiHostBase(request, configManager.get<string>('wopiHostUrl', ''));
|
||||
const tokenPayload = {
|
||||
serverUrl,
|
||||
authHeader: creds.authHeader,
|
||||
@@ -119,6 +119,7 @@ export async function POST(request: NextRequest) {
|
||||
...document,
|
||||
canWrite: editable,
|
||||
origin: request.nextUrl.origin,
|
||||
postMessageOrigin: wopiBrowserOrigin(request),
|
||||
// Signing out of this slot in this browser revokes the token.
|
||||
bid: wopiBrowserBinding(await cookies()),
|
||||
slot: creds.slot,
|
||||
@@ -127,7 +128,7 @@ export async function POST(request: NextRequest) {
|
||||
const { token, expiresAt } = mintWopiToken(tokenPayload);
|
||||
|
||||
return NextResponse.json({
|
||||
url: buildWopiActionUrl(urlsrc, wopiSrc),
|
||||
url: buildWopiActionUrl(urlsrc, wopiSrc, str('lang')),
|
||||
accessToken: token,
|
||||
accessTokenTtl: expiresAt,
|
||||
readOnly: !editable,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { WopiEditor } from '@/components/files/wopi-editor';
|
||||
// back 404, which left office editing permanently "unavailable" there. Both
|
||||
// WOPI requests have to carry the mount prefix.
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'de' }));
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Loader2, X } from "@/components/icons";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
@@ -38,6 +38,7 @@ interface LaunchData {
|
||||
*/
|
||||
export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps) {
|
||||
const t = useTranslations("files");
|
||||
const locale = useLocale();
|
||||
const [launch, setLaunch] = useState<LaunchData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [frameLoaded, setFrameLoaded] = useState(false);
|
||||
@@ -46,8 +47,8 @@ export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps
|
||||
// Launch once per document; the object itself is rebuilt on every render.
|
||||
const launchBody = JSON.stringify(
|
||||
target.kind === "attachment"
|
||||
? { blobId: target.blobId, name: target.name, type: target.type, size: target.size, accountId: accountId || undefined }
|
||||
: { fileId: target.id, accountId: accountId || undefined },
|
||||
? { blobId: target.blobId, name: target.name, type: target.type, size: target.size, accountId: accountId || undefined, lang: locale }
|
||||
: { fileId: target.id, accountId: accountId || undefined, lang: locale },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -298,9 +298,10 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
// from `<url>/hosting/discovery` unless the URL already carries a path.
|
||||
// Empty = feature off.
|
||||
wopiClientUrl: { envVar: 'WOPI_CLIENT_URL', type: 'url', defaultValue: '' },
|
||||
// How the WOPI editor reaches this webmail (WOPISrc base). Empty = derive
|
||||
// from the request origin; set it when the editor sees a different host
|
||||
// than the browser (docker networks, split DNS).
|
||||
// How the WOPI editor reaches this webmail (WOPISrc base). Empty = the
|
||||
// origin the browser uses; set it when the editor sees a different host
|
||||
// than the browser (docker networks, split DNS). The base path of a
|
||||
// sub-path install is added to a bare origin (#1130).
|
||||
wopiHostUrl: { envVar: 'WOPI_HOST_URL', type: 'url', defaultValue: '' },
|
||||
};
|
||||
|
||||
|
||||
+103
-13
@@ -25,8 +25,29 @@ export interface WopiActions {
|
||||
}
|
||||
|
||||
const DISCOVERY_TTL_MS = 5 * 60 * 1000;
|
||||
// An unreachable editor is asked again sooner, but not on every request: the
|
||||
// proxy reads the discovery for the CSP of each page it serves (#1130).
|
||||
const DISCOVERY_FAILURE_TTL_MS = 30 * 1000;
|
||||
const DISCOVERY_TIMEOUT_MS = 5000;
|
||||
/** How long a page response waits for a discovery it has never seen. */
|
||||
const CSP_DISCOVERY_WAIT_MS = 2000;
|
||||
|
||||
let discoveryCache: { url: string; fetchedAt: number; actions: WopiActions } | null = null;
|
||||
interface DiscoveryState {
|
||||
cache: { url: string; fetchedAt: number; actions: WopiActions | null } | null;
|
||||
inflight: { url: string; promise: Promise<WopiActions | null> } | null;
|
||||
}
|
||||
|
||||
// The proxy and the route handlers are bundled separately; keeping the cache
|
||||
// on globalThis lets them share one discovery fetch (see config-manager.ts).
|
||||
const STATE_KEY = Symbol.for('bulwark.wopi.discovery');
|
||||
type GlobalWithDiscovery = typeof globalThis & { [STATE_KEY]?: DiscoveryState };
|
||||
const g = globalThis as GlobalWithDiscovery;
|
||||
const state: DiscoveryState = (g[STATE_KEY] ??= { cache: null, inflight: null });
|
||||
|
||||
function isFresh(cache: NonNullable<DiscoveryState['cache']>): boolean {
|
||||
const ttl = cache.actions ? DISCOVERY_TTL_MS : DISCOVERY_FAILURE_TTL_MS;
|
||||
return Date.now() - cache.fetchedAt < ttl;
|
||||
}
|
||||
|
||||
export async function getWopiClientUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
@@ -75,16 +96,26 @@ export async function getWopiActions(): Promise<WopiActions | null> {
|
||||
const discoveryUrl = discoveryUrlFor(clientUrl);
|
||||
if (!discoveryUrl) return null;
|
||||
|
||||
if (
|
||||
discoveryCache &&
|
||||
discoveryCache.url === discoveryUrl &&
|
||||
Date.now() - discoveryCache.fetchedAt < DISCOVERY_TTL_MS
|
||||
) {
|
||||
return discoveryCache.actions;
|
||||
if (state.cache && state.cache.url === discoveryUrl && isFresh(state.cache)) {
|
||||
return state.cache.actions;
|
||||
}
|
||||
if (state.inflight?.url === discoveryUrl) return state.inflight.promise;
|
||||
|
||||
const promise = fetchWopiActions(discoveryUrl).then((actions) => {
|
||||
state.cache = { url: discoveryUrl, fetchedAt: Date.now(), actions };
|
||||
if (state.inflight?.promise === promise) state.inflight = null;
|
||||
return actions;
|
||||
});
|
||||
state.inflight = { url: discoveryUrl, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchWopiActions(discoveryUrl: string): Promise<WopiActions | null> {
|
||||
try {
|
||||
const res = await fetch(discoveryUrl, { headers: { Accept: 'text/xml' } });
|
||||
const res = await fetch(discoveryUrl, {
|
||||
headers: { Accept: 'text/xml' },
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn('WOPI discovery fetch failed', { discoveryUrl, status: res.status });
|
||||
return null;
|
||||
@@ -94,7 +125,6 @@ export async function getWopiActions(): Promise<WopiActions | null> {
|
||||
logger.warn('WOPI discovery returned no actions', { discoveryUrl });
|
||||
return null;
|
||||
}
|
||||
discoveryCache = { url: discoveryUrl, fetchedAt: Date.now(), actions };
|
||||
return actions;
|
||||
} catch (error) {
|
||||
logger.warn('WOPI discovery unreachable', {
|
||||
@@ -105,15 +135,75 @@ export async function getWopiActions(): Promise<WopiActions | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function httpOrigin(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url.replace(/<[^>]*>/g, ''));
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The origins the browser loads the editor from, for the page CSP (#1130).
|
||||
*
|
||||
* `wopiClientUrl` is where the server fetches discovery, which behind a
|
||||
* reverse proxy is often an internal address the browser never sees; the
|
||||
* editor itself is served from the origins of the discovery `urlsrc`s (for
|
||||
* Collabora, its `server_name`). Both are returned. A discovery that went
|
||||
* stale is used as is and refreshed in the background, so only the first page
|
||||
* after boot (or after an outage) waits for the editor.
|
||||
*/
|
||||
export async function getWopiEditorOrigins(): Promise<string[]> {
|
||||
const clientUrl = await getWopiClientUrl();
|
||||
if (!clientUrl) return [];
|
||||
const discoveryUrl = discoveryUrlFor(clientUrl);
|
||||
|
||||
let actions: WopiActions | null = null;
|
||||
const cached = state.cache?.url === discoveryUrl ? state.cache : null;
|
||||
if (cached && (cached.actions || isFresh(cached))) {
|
||||
actions = cached.actions;
|
||||
if (!isFresh(cached)) void getWopiActions();
|
||||
} else if (discoveryUrl) {
|
||||
actions = await Promise.race([
|
||||
getWopiActions(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), CSP_DISCOVERY_WAIT_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
const origins = new Set<string>();
|
||||
const add = (url: string) => {
|
||||
const origin = httpOrigin(url);
|
||||
if (origin) origins.add(origin);
|
||||
};
|
||||
add(clientUrl);
|
||||
if (actions) {
|
||||
for (const urlsrc of [...Object.values(actions.edit), ...Object.values(actions.view)]) add(urlsrc);
|
||||
}
|
||||
return Array.from(origins);
|
||||
}
|
||||
|
||||
/** UI language as a BCP 47 tag, or '' when it is not one. */
|
||||
function languageTag(lang: string | undefined): string {
|
||||
const tag = (lang ?? '').trim();
|
||||
return /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(tag) ? tag : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the editor launch URL from a discovery `urlsrc` and the WOPISrc of
|
||||
* the file. Placeholder groups like `<ui=UI_LLCC&>` are optional per the WOPI
|
||||
* spec and are dropped.
|
||||
* spec: the language ones are filled in from `lang`, the rest are dropped.
|
||||
* Collabora's discovery has no placeholders and reads a `lang` parameter
|
||||
* instead, so that is added when the urlsrc names no language itself (#1130).
|
||||
*/
|
||||
export function buildWopiActionUrl(urlsrc: string, wopiSrc: string): string {
|
||||
const base = urlsrc.replace(/<[^>]*>/g, '');
|
||||
export function buildWopiActionUrl(urlsrc: string, wopiSrc: string, lang?: string): string {
|
||||
const tag = languageTag(lang);
|
||||
const base = urlsrc.replace(/<([^=<>]+)=([^&<>]*)&?>/g, (_group, name: string, placeholder: string) =>
|
||||
tag && (placeholder === 'UI_LLCC' || placeholder === 'DC_LLCC') ? `${name}=${tag}&` : '',
|
||||
).replace(/<[^>]*>/g, '');
|
||||
const sep = base.includes('?')
|
||||
? (base.endsWith('?') || base.endsWith('&') ? '' : '&')
|
||||
: '?';
|
||||
return `${base}${sep}WOPISrc=${encodeURIComponent(wopiSrc)}`;
|
||||
const language = tag && !/[?&](?:lang|ui)=/.test(base) ? `lang=${tag}&` : '';
|
||||
return `${base}${sep}${language}WOPISrc=${encodeURIComponent(wopiSrc)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
function httpOrigin(value: string | null): string {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin the browser has this webmail open on (#1130).
|
||||
*
|
||||
* `request.nextUrl.origin` is not it behind a reverse proxy: Next's standalone
|
||||
* server builds that URL from HOSTNAME/PORT, so it reads `http://0.0.0.0:3000`.
|
||||
* The launch request is a same-origin POST, which always carries `Origin` -
|
||||
* the very header the CSRF gate checks - so that is the page's origin. Clients
|
||||
* that send none fall back to what the reverse proxy forwarded.
|
||||
*/
|
||||
export function wopiBrowserOrigin(request: NextRequest): string {
|
||||
const origin = httpOrigin(request.headers.get('origin'));
|
||||
if (origin) return origin;
|
||||
|
||||
const host = (request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? '')
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
if (host) {
|
||||
const proto =
|
||||
(request.headers.get('x-forwarded-proto') ?? '').split(',')[0].trim() ||
|
||||
request.nextUrl.protocol.replace(/:$/, '');
|
||||
const forwarded = httpOrigin(`${proto}://${host}`);
|
||||
if (forwarded) return forwarded;
|
||||
}
|
||||
return request.nextUrl.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL the editor calls this webmail on; `/api/wopi/files/<id>` is
|
||||
* appended to form the WOPISrc. Defaults to the browser's origin, and
|
||||
* `wopiHostUrl` overrides it where the editor sees a different host than the
|
||||
* browser (docker networks, split DNS).
|
||||
*
|
||||
* The API only exists under the base path of a sub-path install, so it is
|
||||
* added to the default and to an override that is a bare origin. An override
|
||||
* that carries a path of its own is taken as written.
|
||||
*/
|
||||
export function wopiHostBase(request: NextRequest, override: string): string {
|
||||
const basePath = (request.nextUrl.basePath ?? '').replace(/\/+$/, '');
|
||||
const configured = override.trim().replace(/\/+$/, '');
|
||||
if (!configured) return `${wopiBrowserOrigin(request)}${basePath}`;
|
||||
try {
|
||||
const url = new URL(configured);
|
||||
if (url.pathname === '/' || url.pathname === '') return `${url.origin}${basePath}`;
|
||||
} catch {
|
||||
// Not a URL - nothing to add a path to.
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
+11
-1
@@ -40,8 +40,17 @@ export interface WopiTokenPayload {
|
||||
*/
|
||||
uploadAccountId?: string;
|
||||
canWrite: boolean;
|
||||
/** Browser origin that embeds the editor iframe (WOPI PostMessageOrigin). */
|
||||
/**
|
||||
* The origin this server sees itself on. It decides whether `serverUrl` is
|
||||
* this process (see lib/wopi/request.ts), so it never comes from a header.
|
||||
*/
|
||||
origin: string;
|
||||
/**
|
||||
* Browser origin that embeds the editor iframe (WOPI PostMessageOrigin).
|
||||
* Behind a reverse proxy it differs from `origin` (#1130). Absent on older
|
||||
* tokens - those fall back to `origin`.
|
||||
*/
|
||||
postMessageOrigin?: string;
|
||||
/**
|
||||
* The browser and account slot that minted the token, so signing out
|
||||
* there revokes it (see lib/wopi/revocation.ts). Absent on older tokens.
|
||||
@@ -111,6 +120,7 @@ export function verifyWopiToken(token: string | null, documentId: string): WopiT
|
||||
if (p.kind !== undefined && p.kind !== 'file' && p.kind !== 'attachment') return null;
|
||||
if (p.bid !== undefined && typeof p.bid !== 'string') return null;
|
||||
if (p.slot !== undefined && typeof p.slot !== 'number') return null;
|
||||
if (p.postMessageOrigin !== undefined && typeof p.postMessageOrigin !== 'string') return null;
|
||||
if (p.uploadAccountId !== undefined && (typeof p.uploadAccountId !== 'string' || !p.uploadAccountId)) return null;
|
||||
if (wopiDocumentId(p) !== documentId) return null;
|
||||
if (Date.now() > p.exp) return null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
pickAppFrameOriginsCookie,
|
||||
} from "./lib/security/app-frame-origins";
|
||||
import { configManager } from "./lib/admin/config-manager";
|
||||
import { getWopiEditorOrigins } from "./lib/wopi/discovery";
|
||||
import { detectSetupState } from "./lib/setup/state";
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
@@ -297,18 +298,14 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
// WOPI document editor (#425): the editor is launched by POSTing the access
|
||||
// token into an iframe on its origin, so that origin must be allowed in both
|
||||
// frame-src AND form-action (which is otherwise 'self').
|
||||
let wopiOrigin = "";
|
||||
try {
|
||||
const wopiClientUrl = configManager.get<string>("wopiClientUrl", "").trim();
|
||||
if (wopiClientUrl) wopiOrigin = new URL(wopiClientUrl).origin;
|
||||
} catch {
|
||||
// Invalid admin-supplied URL - leave the CSP unchanged.
|
||||
}
|
||||
// frame-src AND form-action (which is otherwise 'self'). The browser loads
|
||||
// it from the origins the editor's discovery names, which are not that of
|
||||
// wopiClientUrl when discovery is fetched over an internal address (#1130).
|
||||
const wopiOrigins = await getWopiEditorOrigins();
|
||||
|
||||
const frameOrigins: string[] = [];
|
||||
const seenFrameOrigins = new Set<string>();
|
||||
for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...(wopiOrigin ? [wopiOrigin] : [])]) {
|
||||
for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...wopiOrigins]) {
|
||||
const key = origin.toLowerCase();
|
||||
if (seenFrameOrigins.has(key)) continue;
|
||||
seenFrameOrigins.add(key);
|
||||
@@ -330,7 +327,7 @@ export async function proxy(request: NextRequest) {
|
||||
frameSrc,
|
||||
`object-src 'self' blob:`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'${wopiOrigin ? ` ${wopiOrigin}` : ""}`,
|
||||
`form-action ${["'self'", ...wopiOrigins].join(" ")}`,
|
||||
`frame-ancestors ${frameAncestors}`,
|
||||
`media-src 'self' blob:`,
|
||||
].join("; ");
|
||||
|
||||
Reference in New Issue
Block a user