From 7e1a659c06eb3f79d19a1a4c57b5fed6324ae9d4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:16:29 +0200 Subject: [PATCH] fix: open the WOPI editor behind a reverse proxy with split addresses #1130 --- app/(main)/admin/_tabs/settings.tsx | 4 +- app/api/wopi/files/[fileId]/route.ts | 4 +- app/api/wopi/launch/route.ts | 9 +- .../files/__tests__/wopi-base-path.test.tsx | 2 +- components/files/wopi-editor.tsx | 7 +- lib/__tests__/wopi-reverse-proxy.test.ts | 237 ++++++++++++++++++ lib/admin/types.ts | 7 +- lib/wopi/discovery.ts | 116 ++++++++- lib/wopi/origin.ts | 60 +++++ lib/wopi/token.ts | 12 +- proxy.ts | 17 +- 11 files changed, 436 insertions(+), 39 deletions(-) create mode 100644 lib/__tests__/wopi-reverse-proxy.test.ts create mode 100644 lib/wopi/origin.ts diff --git a/app/(main)/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx index c8e5c18bb..2fa1e71a7 100644 --- a/app/(main)/admin/_tabs/settings.tsx +++ b/app/(main)/admin/_tabs/settings.tsx @@ -163,10 +163,10 @@ export function SettingsTab() { - +

- 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.

diff --git a/app/api/wopi/files/[fileId]/route.ts b/app/api/wopi/files/[fileId]/route.ts index 5cc0f1972..c8dae34e7 100644 --- a/app/api/wopi/files/[fileId]/route.ts +++ b/app/api/wopi/files/[fileId]/route.ts @@ -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', { diff --git a/app/api/wopi/launch/route.ts b/app/api/wopi/launch/route.ts index d28b5c66b..ef85340c3 100644 --- a/app/api/wopi/launch/route.ts +++ b/app/api/wopi/launch/route.ts @@ -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('wopiHostUrl', '').trim().replace(/\/+$/, '') || - request.nextUrl.origin; + const hostBase = wopiHostBase(request, configManager.get('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, diff --git a/components/files/__tests__/wopi-base-path.test.tsx b/components/files/__tests__/wopi-base-path.test.tsx index 9a3fc8957..c1facd996 100644 --- a/components/files/__tests__/wopi-base-path.test.tsx +++ b/components/files/__tests__/wopi-base-path.test.tsx @@ -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; diff --git a/components/files/wopi-editor.tsx b/components/files/wopi-editor.tsx index ec647d0bf..8fb3f8a82 100644 --- a/components/files/wopi-editor.tsx +++ b/components/files/wopi-editor.tsx @@ -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(null); const [error, setError] = useState(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(() => { diff --git a/lib/__tests__/wopi-reverse-proxy.test.ts b/lib/__tests__/wopi-reverse-proxy.test.ts new file mode 100644 index 000000000..f81408cb9 --- /dev/null +++ b/lib/__tests__/wopi-reverse-proxy.test.ts @@ -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 })); + +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 = ` + + + + +`; + +let discoveryFetch: Mock; +let internalHost = 0; + +function request( + pathname: string, + init: { body?: unknown; headers?: Record; 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[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?', 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); + }); +}); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index adceaf4df..25f7a1e7a 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -298,9 +298,10 @@ export const CONFIG_ENV_MAP: Record/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: '' }, }; diff --git a/lib/wopi/discovery.ts b/lib/wopi/discovery.ts index 58e4b054a..735127816 100644 --- a/lib/wopi/discovery.ts +++ b/lib/wopi/discovery.ts @@ -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 } | 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): boolean { + const ttl = cache.actions ? DISCOVERY_TTL_MS : DISCOVERY_FAILURE_TTL_MS; + return Date.now() - cache.fetchedAt < ttl; +} export async function getWopiClientUrl(): Promise { await configManager.ensureLoaded(); @@ -75,16 +96,26 @@ export async function getWopiActions(): Promise { 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 { 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 { 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 { } } +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 { + 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((resolve) => setTimeout(() => resolve(null), CSP_DISCOVERY_WAIT_MS)), + ]); + } + + const origins = new Set(); + 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 `` 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)}`; } diff --git a/lib/wopi/origin.ts b/lib/wopi/origin.ts new file mode 100644 index 000000000..32a247a91 --- /dev/null +++ b/lib/wopi/origin.ts @@ -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/` 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; +} diff --git a/lib/wopi/token.ts b/lib/wopi/token.ts index 67b0593dc..5ec3f2f79 100644 --- a/lib/wopi/token.ts +++ b/lib/wopi/token.ts @@ -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; diff --git a/proxy.ts b/proxy.ts index 83c17ba72..ccc08fc87 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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("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(); - 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("; ");