Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 45 additions & 15 deletions lib/Listener/AddContentSecurityPolicyListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 . '/');
}
}
27 changes: 26 additions & 1 deletion lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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), '/');
}
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/components/ReadOnlyViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 2 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/utils/excalidrawAssetPath.ts
Original file line number Diff line number Diff line change
@@ -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/')
113 changes: 113 additions & 0 deletions tests/Unit/Listener/AddContentSecurityPolicyListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Whiteboard\Listener;

use OC\Security\CSP\ContentSecurityPolicy;
use OC\Security\CSP\ContentSecurityPolicyManager;
use OCA\Whiteboard\Service\ConfigService;
use OCP\AppFramework\Services\IAppConfig;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\IRequest;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use PHPUnit\Framework\Attributes\DataProvider;
use Test\TestCase;

class AddContentSecurityPolicyListenerTest extends TestCase {
#[DataProvider('whiteboardPageProvider')]
public function testAddsCollaborationCspOnWhiteboardPages(string $pathInfo): void {
$policy = $this->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));
}
}
91 changes: 91 additions & 0 deletions tests/Unit/Service/ConfigServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Whiteboard\Service;

use OCP\AppFramework\Services\IAppConfig;
use OCP\IConfig;
use PHPUnit\Framework\Attributes\DataProvider;
use Test\TestCase;

class ConfigServiceTest extends TestCase {
#[DataProvider('backendCspDomainProvider')]
public function testCollabBackendCspConnectDomains(string $url, array $expected): void {
$service = $this->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));
}
}
Loading
Loading