fix: open the WOPI editor behind a reverse proxy with split addresses #1130

This commit is contained in:
Linus Rath
2026-10-04 22:16:29 +02:00
parent b51de3baaa
commit 7e1a659c06
11 changed files with 436 additions and 39 deletions
+2 -2
View File
@@ -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&apos;s base path is added to it. The editor must also allow this webmail as a WOPI host.
</p>
</div>
</SettingsSection>
+2 -2
View File
@@ -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', {
+5 -4
View File
@@ -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>;
+4 -3
View File
@@ -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(() => {
+237
View File
@@ -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
View File
@@ -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
View File
@@ -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)}`;
}
+60
View File
@@ -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
View File
@@ -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;
+7 -10
View File
@@ -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("; ");