diff --git a/lib/Listener/AddContentSecurityPolicyListener.php b/lib/Listener/AddContentSecurityPolicyListener.php index 882f55fe..ccda50e1 100644 --- a/lib/Listener/AddContentSecurityPolicyListener.php +++ b/lib/Listener/AddContentSecurityPolicyListener.php @@ -29,36 +29,66 @@ public function handle(Event $event): void { return; } - if (!$this->isPageLoad() || !$this->isWhiteboardPage()) { - return; - } - - $domains = $this->configService->getCollabBackendCspConnectDomains(); - if ($domains === []) { + if (!$this->isWhiteboardPage()) { return; } $policy = new EmptyContentSecurityPolicy(); - foreach ($domains as $domain) { + $policy->addAllowedWorkerSrcDomain('\'self\''); + + foreach ($this->configService->getCollabBackendCspConnectDomains() as $domain) { $policy->addAllowedConnectDomain($domain); } $event->addPolicy($policy); } - private function isPageLoad(): bool { - $scriptNameParts = explode('/', $this->request->getScriptName()); - return end($scriptNameParts) === 'index.php'; - } - private function isWhiteboardPage(): bool { + if ($this->request->getMethod() !== 'GET') { + return false; + } + $pathInfo = $this->request->getPathInfo(); if (!is_string($pathInfo)) { return false; } - return str_starts_with($pathInfo, '/apps/files') - || str_starts_with($pathInfo, '/apps/whiteboard/recording') - || str_starts_with($pathInfo, '/s/'); + $pathInfo = $this->normalizePathInfo($pathInfo); + + // The /apps/files prefix also covers non-page GET endpoints. Keep this + // listener scoped to page shells that can host Whiteboard instead of adding + // Whiteboard collaboration sources to Files API responses or service workers. + if ($this->pathMatches($pathInfo, '/apps/files/api') + || $this->pathMatches($pathInfo, '/apps/files/preview-service-worker.js')) { + return false; + } + + return $this->pathMatches($pathInfo, '/apps/files') + || $this->pathMatches($pathInfo, '/apps/whiteboard/recording') + || $this->pathMatches($pathInfo, '/apps/spreed') + || $this->pathMatches($pathInfo, '/call') + || $this->pathMatches($pathInfo, '/f') + || $this->pathMatches($pathInfo, '/s'); + } + + private function normalizePathInfo(string $pathInfo): string { + $path = parse_url($pathInfo, PHP_URL_PATH); + if (is_string($path)) { + $pathInfo = $path; + } + + if ($pathInfo === '/index.php') { + return '/'; + } + + if (str_starts_with($pathInfo, '/index.php/')) { + return substr($pathInfo, strlen('/index.php')); + } + + return $pathInfo; + } + + private function pathMatches(string $pathInfo, string $prefix): bool { + return $pathInfo === $prefix || str_starts_with($pathInfo, $prefix . '/'); } } diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index eed531df..6734f0af 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -83,7 +83,11 @@ public function getCollabBackendCspConnectDomains(): array { return []; } - $host = $parts['host']; + $host = strtolower($parts['host']); + if (!$this->isValidCspHost($host)) { + return []; + } + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; $origin = $scheme . '://' . $host . $port; @@ -101,6 +105,27 @@ public function getCollabBackendCspConnectDomains(): array { return array_values(array_unique($domains)); } + private function isValidCspHost(string $host): bool { + if ($host === '' || strlen($host) > 255) { + return false; + } + + if (str_starts_with($host, '[') || str_ends_with($host, ']')) { + if (!str_starts_with($host, '[') || !str_ends_with($host, ']')) { + return false; + } + + return filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + return true; + } + + return preg_match('/^[a-z0-9._-]+$/', $host) === 1 + && !str_contains($host, '..'); + } + private function trimUrl(string $url): string { return rtrim(trim($url), '/'); } diff --git a/src/App.tsx b/src/App.tsx index 0a1477ea..acf47eef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import './utils/excalidrawAssetPath' + import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { getCurrentUser } from '@nextcloud/auth' import { translate as t } from '@nextcloud/l10n' diff --git a/src/components/ReadOnlyViewer.tsx b/src/components/ReadOnlyViewer.tsx index e3c1bfcc..a3f22403 100644 --- a/src/components/ReadOnlyViewer.tsx +++ b/src/components/ReadOnlyViewer.tsx @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import '../utils/excalidrawAssetPath' + import { memo, useEffect, useMemo, useState } from 'react' import { t } from '@nextcloud/l10n' import { Excalidraw as ExcalidrawComponent, restoreElements } from '@nextcloud/excalidraw' diff --git a/src/main.ts b/src/main.ts index e4842200..42c1c342 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,8 +6,9 @@ import type Vue from 'vue' import type { ComponentOptions, CreateElement, VNode } from 'vue' +import './utils/excalidrawAssetPath' + import { loadState } from '@nextcloud/initial-state' -import { linkTo } from '@nextcloud/router' import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' import './styles/index.scss' @@ -19,14 +20,6 @@ import { import type { WhiteboardRootHandle } from './utils/renderWhiteboardView' import { callMobileMessage } from './utils/mobileInterface' -declare global { - interface Window { - EXCALIDRAW_ASSET_PATH?: string | string[] - } -} - -window.EXCALIDRAW_ASSET_PATH = linkTo('whiteboard', 'dist/') - type RecordingContext = { fileId: number collabBackendUrl: string diff --git a/src/utils/excalidrawAssetPath.ts b/src/utils/excalidrawAssetPath.ts new file mode 100644 index 00000000..165e5b1e --- /dev/null +++ b/src/utils/excalidrawAssetPath.ts @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { linkTo } from '@nextcloud/router' + +declare global { + interface Window { + EXCALIDRAW_ASSET_PATH?: string | string[] + } +} + +window.EXCALIDRAW_ASSET_PATH = linkTo('whiteboard', 'dist/') diff --git a/tests/Unit/Listener/AddContentSecurityPolicyListenerTest.php b/tests/Unit/Listener/AddContentSecurityPolicyListenerTest.php new file mode 100644 index 00000000..01a50354 --- /dev/null +++ b/tests/Unit/Listener/AddContentSecurityPolicyListenerTest.php @@ -0,0 +1,113 @@ +handleRequest($pathInfo); + + $this->assertContains('https://whiteboard.example.com:3002', $policy->getAllowedConnectDomains()); + $this->assertContains('wss://whiteboard.example.com:3002', $policy->getAllowedConnectDomains()); + $this->assertContains('\'self\'', $policy->getAllowedWorkerSrcDomains()); + $this->assertNotContains('*', $policy->getAllowedConnectDomains()); + $this->assertNotContains('*', $policy->getAllowedWorkerSrcDomains()); + } + + public static function whiteboardPageProvider(): array { + return [ + 'files app' => ['/apps/files'], + 'files app with index.php' => ['/index.php/apps/files/files/123?dir=/&openfile=true'], + 'files direct editing' => ['/apps/files/directEditing/token'], + 'direct file route' => ['/f/12345'], + 'direct file route with index.php' => ['/index.php/f/12345'], + 'public share route' => ['/s/shareToken'], + 'public share route with index.php' => ['/index.php/s/shareToken'], + 'talk app' => ['/apps/spreed'], + 'talk room' => ['/apps/spreed/room/abc123'], + 'talk room with index.php' => ['/index.php/apps/spreed/room/abc123'], + 'talk call route' => ['/call/abc123'], + 'talk call route with index.php' => ['/index.php/call/abc123'], + 'recording route' => ['/apps/whiteboard/recording/123/alice'], + ]; + } + + #[DataProvider('nonWhiteboardPageProvider')] + public function testDoesNotAddCollaborationCspOutsideWhiteboardPages(string $pathInfo, string $method = 'GET'): void { + $policy = $this->handleRequest($pathInfo, $method); + + $this->assertNotContains('https://whiteboard.example.com:3002', $policy->getAllowedConnectDomains()); + $this->assertNotContains('wss://whiteboard.example.com:3002', $policy->getAllowedConnectDomains()); + $this->assertNotContains('\'self\'', $policy->getAllowedWorkerSrcDomains()); + } + + public static function nonWhiteboardPageProvider(): array { + return [ + 'other app page' => ['/apps/dashboard'], + 'files api' => ['/apps/files/api/v1/thumbnail/32/32/Photos/image.jpg'], + 'files api with index.php' => ['/index.php/apps/files/api/v1/thumbnail/32/32/Photos/image.jpg'], + 'files service worker route' => ['/apps/files/preview-service-worker.js'], + 'files service worker route with index.php' => ['/index.php/apps/files/preview-service-worker.js'], + 'whiteboard data api' => ['/apps/whiteboard/12345'], + 'files post request' => ['/apps/files', 'POST'], + 'talk post request' => ['/apps/spreed', 'POST'], + 'files_external app is not files' => ['/apps/files_external'], + ]; + } + + public function testAddsWorkerSourceEvenWithoutConfiguredBackend(): void { + $policy = $this->handleRequest('/apps/spreed/room/abc123', 'GET', ''); + + $this->assertContains('\'self\'', $policy->getAllowedWorkerSrcDomains()); + $this->assertSame(['\'self\''], $policy->getAllowedConnectDomains()); + } + + private function handleRequest( + string $pathInfo, + string $method = 'GET', + string $backendUrl = 'https://whiteboard.example.com:3002/socket.io/', + ): ContentSecurityPolicy { + $request = $this->createMock(IRequest::class); + $request->method('getMethod')->willReturn($method); + $request->method('getPathInfo')->willReturn($pathInfo); + + $listener = new AddContentSecurityPolicyListener( + $request, + $this->createConfigService($backendUrl), + ); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $policyManager = new ContentSecurityPolicyManager($dispatcher); + + $listener->handle(new AddContentSecurityPolicyEvent($policyManager)); + + return $policyManager->getDefaultPolicy(); + } + + private function createConfigService(string $backendUrl): ConfigService { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueString') + ->with('collabBackendUrl') + ->willReturn($backendUrl); + + return new ConfigService($appConfig, $this->createMock(IConfig::class)); + } +} diff --git a/tests/Unit/Service/ConfigServiceTest.php b/tests/Unit/Service/ConfigServiceTest.php new file mode 100644 index 00000000..5d85cefc --- /dev/null +++ b/tests/Unit/Service/ConfigServiceTest.php @@ -0,0 +1,91 @@ +createService($url); + + $this->assertSame($expected, $service->getCollabBackendCspConnectDomains()); + } + + public static function backendCspDomainProvider(): array { + return [ + 'https backend also allows wss' => [ + 'https://whiteboard.example.com/socket.io/', + ['https://whiteboard.example.com', 'wss://whiteboard.example.com'], + ], + 'http backend with port also allows ws' => [ + 'http://whiteboard.local:3002/base/', + ['http://whiteboard.local:3002', 'ws://whiteboard.local:3002'], + ], + 'wss backend also allows https' => [ + 'wss://whiteboard.example.com:8443/socket.io', + ['wss://whiteboard.example.com:8443', 'https://whiteboard.example.com:8443'], + ], + 'ws backend also allows http' => [ + 'ws://127.0.0.1:3002/socket.io', + ['ws://127.0.0.1:3002', 'http://127.0.0.1:3002'], + ], + 'ipv6 backend' => [ + 'https://[::1]:3002/socket.io', + ['https://[::1]:3002', 'wss://[::1]:3002'], + ], + 'underscore backend host' => [ + 'https://whiteboard_backend.local:3002/socket.io', + ['https://whiteboard_backend.local:3002', 'wss://whiteboard_backend.local:3002'], + ], + 'trailing dot fqdn backend host' => [ + 'https://whiteboard.example.com.:3002/socket.io', + ['https://whiteboard.example.com.:3002', 'wss://whiteboard.example.com.:3002'], + ], + 'dash edge backend labels' => [ + 'https://-whiteboard.example-.local:3002/socket.io', + ['https://-whiteboard.example-.local:3002', 'wss://-whiteboard.example-.local:3002'], + ], + ]; + } + + #[DataProvider('invalidBackendUrlProvider')] + public function testInvalidCollabBackendUrlsAreNotUsedForCsp(string $url): void { + $service = $this->createService($url); + + $this->assertSame([], $service->getCollabBackendCspConnectDomains()); + } + + public static function invalidBackendUrlProvider(): array { + return [ + 'empty' => [''], + 'missing scheme' => ['//whiteboard.example.com'], + 'unsupported scheme' => ['ftp://whiteboard.example.com'], + 'javascript scheme' => ['javascript:alert(1)'], + 'host with csp delimiter' => ['https://whiteboard.example.com;script-src *'], + 'host with quoted source injection' => ["https://whiteboard.example.com 'unsafe-eval'"], + 'wildcard host' => ['https://*.example.com'], + 'empty label' => ['https://whiteboard..example.com'], + 'invalid ipv6 literal' => ['https://[not-ipv6]:3002'], + ]; + } + + private function createService(string $backendUrl): ConfigService { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueString') + ->with('collabBackendUrl') + ->willReturn($backendUrl); + + return new ConfigService($appConfig, $this->createMock(IConfig::class)); + } +} diff --git a/tests/integration/font-copy-config.spec.mjs b/tests/integration/font-copy-config.spec.mjs new file mode 100644 index 00000000..f5c2f5d8 --- /dev/null +++ b/tests/integration/font-copy-config.spec.mjs @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { readdir, readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' + +import { transformExcalidrawSelfHostedFontFallback } from '../../vite.config.ts' + +const findExcalidrawFallbackChunk = async () => { + const prodDir = new URL('../../node_modules/@nextcloud/excalidraw/dist/prod/', import.meta.url) + const files = await readdir(prodDir) + const chunks = [] + + for (const file of files.filter((entry) => entry.endsWith('.js'))) { + const code = await readFile(new URL(file, prodDir), 'utf8') + if (code.includes('ASSETS_FALLBACK_URL')) { + chunks.push({ file, code }) + } + } + + return chunks +} + +describe('Excalidraw font assets', () => { + it('copies fonts to dist/fonts instead of preserving node_modules in the URL path', async () => { + const config = await readFile(new URL('../../vite.config.ts', import.meta.url), 'utf8') + + expect(config).toContain('src: EXCALIDRAW_FONTS_DIR') + expect(config).toMatch(/dest: 'dist'/) + expect(config).toContain('rename: { stripBase: 5 }') + }) + + it('sets Excalidraw asset path before runtime Excalidraw imports', async () => { + const [assetPath, main, app, readOnlyViewer] = await Promise.all([ + readFile(new URL('../../src/utils/excalidrawAssetPath.ts', import.meta.url), 'utf8'), + readFile(new URL('../../src/main.ts', import.meta.url), 'utf8'), + readFile(new URL('../../src/App.tsx', import.meta.url), 'utf8'), + readFile(new URL('../../src/components/ReadOnlyViewer.tsx', import.meta.url), 'utf8'), + ]) + + expect(assetPath).toContain("window.EXCALIDRAW_ASSET_PATH = linkTo('whiteboard', 'dist/')") + expect(main).toContain("import './utils/excalidrawAssetPath'") + expect(app).toContain("import './utils/excalidrawAssetPath'") + expect(readOnlyViewer).toContain("import '../utils/excalidrawAssetPath'") + }) + + it('rewrites the current Excalidraw production font fallback chunk', async () => { + const chunks = await findExcalidrawFallbackChunk() + + expect(chunks).toHaveLength(1) + + const [{ code }] = chunks + const transformed = transformExcalidrawSelfHostedFontFallback(code) + + expect(transformed).not.toBeNull() + expect(transformed).not.toBe(code) + expect(transformed).toMatch(/return [A-Za-z_$][\w$]*\.length \|\| [A-Za-z_$][\w$]*\.push\(new URL\([^,]+, [A-Za-z_$][\w$]*\.ASSETS_FALLBACK_URL\)\), [A-Za-z_$][\w$]*/) + expect(transformed).not.toMatch(/return [A-Za-z_$][\w$]*\.push\(new URL\([^,]+,[A-Za-z_$][\w$]*\.ASSETS_FALLBACK_URL\)\),[A-Za-z_$][\w$]*/) + }) + + it('fails loudly if Excalidraw exposes a CDN fallback but the bundle shape changes', () => { + expect(() => transformExcalidrawSelfHostedFontFallback('class Changed { static ASSETS_FALLBACK_URL = "https://esm.sh/example" }')).toThrow('ASSETS_FALLBACK_URL') + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 6ace971c..07aec0ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,11 +3,51 @@ import { createAppConfig } from '@nextcloud/vite-config' import react from '@vitejs/plugin-react' -import { defineConfig, normalizePath } from 'vite' +import { defineConfig, normalizePath, type Plugin } from 'vite' import { join, resolve } from 'path' import { viteStaticCopy } from 'vite-plugin-static-copy' const EXCALIDRAW_FONTS_DIR = normalizePath(resolve('node_modules/@nextcloud/excalidraw/dist/prod/fonts')) +const EXCALIDRAW_FALLBACK_URL_PATTERN = /return ([A-Za-z_$][\w$]*)\.push\(new URL\(([^,]+),([A-Za-z_$][\w$]*)\.ASSETS_FALLBACK_URL\)\),\1/g + +export function transformExcalidrawSelfHostedFontFallback(code: string): string | null { + if (!code.includes('ASSETS_FALLBACK_URL')) { + return null + } + + let replacements = 0 + const transformed = code.replace( + EXCALIDRAW_FALLBACK_URL_PATTERN, + (_match, urlsVariable: string, fontUrl: string, fallbackClass: string) => { + replacements++ + return `return ${urlsVariable}.length || ${urlsVariable}.push(new URL(${fontUrl}, ${fallbackClass}.ASSETS_FALLBACK_URL)), ${urlsVariable}` + }, + ) + + if (replacements === 0) { + throw new Error('Excalidraw self-hosted font fallback rewrite failed: ASSETS_FALLBACK_URL is present, but the expected fallback URL expression was not found.') + } + + return transformed +} + +function excalidrawSelfHostedFontFallbackPlugin(): Plugin { + return { + name: 'whiteboard-excalidraw-self-hosted-fonts', + enforce: 'post', + renderChunk(code) { + const transformed = transformExcalidrawSelfHostedFontFallback(code) + if (transformed === null) { + return null + } + + return { + code: transformed, + map: null, + } + }, + } +} const AppConfig = createAppConfig({ main: resolve(join('src', 'main.ts')), @@ -72,9 +112,11 @@ const AppConfig = createAppConfig({ { src: EXCALIDRAW_FONTS_DIR, dest: 'dist', + rename: { stripBase: 5 }, }, ], }), + excalidrawSelfHostedFontFallbackPlugin(), ], }),