diff options
-rw-r--r-- | appinfo/routes.php | 1 | ||||
-rw-r--r-- | lib/AppConfig.php | 13 | ||||
-rw-r--r-- | lib/AppInfo/Application.php | 83 | ||||
-rw-r--r-- | lib/Controller/DirectViewController.php | 12 | ||||
-rw-r--r-- | lib/Controller/DocumentController.php | 99 | ||||
-rw-r--r-- | lib/Controller/DocumentTrait.php | 42 | ||||
-rw-r--r-- | lib/Listener/CSPListener.php | 126 | ||||
-rw-r--r-- | lib/Service/FederationService.php | 25 | ||||
-rw-r--r-- | src/files.js | 56 | ||||
-rw-r--r-- | src/services/preload.js | 43 | ||||
-rw-r--r-- | src/view/FilesAppIntegration.js | 40 | ||||
-rw-r--r-- | tests/lib/Listener/CSPListenerTest.php | 211 |
12 files changed, 423 insertions, 328 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php index 5e358e0d..760e0a83 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -30,7 +30,6 @@ return [ //documents ['name' => 'document#index', 'url' => 'index', 'verb' => 'GET'], ['name' => 'document#remote', 'url' => 'remote', 'verb' => 'GET'], - ['name' => 'document#openRemoteFile', 'url' => 'open', 'verb' => 'GET'], ['name' => 'document#createFromTemplate', 'url' => 'indexTemplate', 'verb' => 'GET'], ['name' => 'document#publicPage', 'url' => '/public', 'verb' => 'GET'], diff --git a/lib/AppConfig.php b/lib/AppConfig.php index 6a84dd0c..f8f54f59 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -15,6 +15,9 @@ use OCA\Richdocuments\AppInfo\Application; use \OCP\IConfig; class AppConfig { + public const WOPI_URL = 'wopi_url'; + public const WOPI_URL_PUBLIC = 'wopi_url_public'; + public const FEDERATION_USE_TRUSTED_DOMAINS = 'federation_use_trusted_domains'; public const SYSTEM_GS_TRUSTED_HOSTS = 'gs.trustedHosts'; @@ -113,7 +116,7 @@ class AppConfig { /** * Returns a list of trusted domains from the gs.trustedHosts config */ - public function getTrustedDomains(): array { + public function getGlobalScaleTrustedHosts(): array { return $this->config->getSystemValue(self::SYSTEM_GS_TRUSTED_HOSTS, []); } @@ -123,4 +126,12 @@ class AppConfig { public function isTrustedDomainAllowedForFederation(): bool { return $this->config->getAppValue(Application::APPNAME, self::FEDERATION_USE_TRUSTED_DOMAINS, 'no') === 'yes'; } + + public function getCollaboraUrlPublic(): string { + return $this->config->getAppValue(Application::APPNAME, self::WOPI_URL_PUBLIC, $this->getCollaboraUrlInternal()); + } + + public function getCollaboraUrlInternal(): string { + return $this->config->getAppValue(Application::APPNAME, self::WOPI_URL, ''); + } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e75905be..238bac7b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,9 +25,9 @@ namespace OCA\Richdocuments\AppInfo; use OC\EventDispatcher\SymfonyAdapter; -use OC\Security\CSP\ContentSecurityPolicy; use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Capabilities; +use OCA\Richdocuments\Listener\CSPListener; use OCA\Richdocuments\Middleware\WOPIMiddleware; use OCA\Richdocuments\Listener\FileCreatedFromTemplateListener; use OCA\Richdocuments\PermissionManager; @@ -37,7 +37,6 @@ use OCA\Richdocuments\Preview\OOXML; use OCA\Richdocuments\Preview\OpenDocument; use OCA\Richdocuments\Preview\Pdf; use OCA\Richdocuments\Service\CapabilitiesService; -use OCA\Richdocuments\Service\FederationService; use OCA\Richdocuments\Service\InitialStateService; use OCA\Richdocuments\Template\CollaboraTemplateProvider; use OCA\Richdocuments\WOPI\DiscoveryManager; @@ -53,13 +52,13 @@ use OCP\Files\Template\TemplateFileCreator; use OCP\IConfig; use OCP\IL10N; use OCP\IPreview; +use OCP\Security\CSP\AddContentSecurityPolicyEvent; class Application extends App implements IBootstrap { public const APPNAME = 'richdocuments'; public function __construct(array $urlParams = array()) { parent::__construct(self::APPNAME, $urlParams); - $this->getContainer()->registerCapability(Capabilities::class); } @@ -68,6 +67,7 @@ class Application extends App implements IBootstrap { $context->registerCapability(Capabilities::class); $context->registerMiddleWare(WOPIMiddleware::class); $context->registerEventListener(FileCreatedFromTemplateEvent::class, FileCreatedFromTemplateListener::class); + $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class); } public function boot(IBootContext $context): void { @@ -160,7 +160,6 @@ class Application extends App implements IBootstrap { } $this->registerProvider(); - $this->updateCSP(); $this->checkAndEnableCODEServer(); }); } @@ -192,68 +191,6 @@ class Application extends App implements IBootstrap { }); } - public function updateCSP() { - $container = $this->getContainer(); - - // Do not apply CSP rules on WebDAV/OCS - // Ideally this could be a middleware running after the controller execution before rendering the result to only do it on page response - $scriptNameParts = explode('/', $container->getServer()->getRequest()->getScriptName()); - $scriptFile = end($scriptNameParts); - if ($scriptFile !== 'index.php') { - return; - } - - $publicWopiUrl = $container->getServer()->getConfig()->getAppValue('richdocuments', 'public_wopi_url', ''); - $publicWopiUrl = $publicWopiUrl === '' ? \OC::$server->getConfig()->getAppValue('richdocuments', 'wopi_url') : $publicWopiUrl; - $cspManager = $container->getServer()->getContentSecurityPolicyManager(); - $policy = new ContentSecurityPolicy(); - if ($publicWopiUrl !== '') { - $policy->addAllowedFrameDomain('\'self\''); - $policy->addAllowedFrameDomain($this->domainOnly($publicWopiUrl)); - $policy->addAllowedFormActionDomain($this->domainOnly($publicWopiUrl)); - } - - /** - * Dynamically add CSP for federated editing - */ - if ($container->getServer()->getAppManager()->isEnabledForUser('federation')) { - /** @var FederationService $federationService */ - $federationService = \OC::$server->query(FederationService::class); - - // Always add trusted servers on global scale - /** @var \OCP\GlobalScale\IConfig $globalScale */ - $globalScale = $container->query(\OCP\GlobalScale\IConfig::class); - if ($globalScale->isGlobalScaleEnabled()) { - $trustedList = \OC::$server->getConfig()->getSystemValue('gs.trustedHosts', []); - foreach ($trustedList as $server) { - $policy->addAllowedFrameDomain($server); - $this->addTrustedRemote($policy, $server); - $policy->addAllowedFormActionDomain($server); - } - } - $remoteAccess = $container->getServer()->getRequest()->getParam('richdocuments_remote_access'); - - if ($remoteAccess && $federationService->isTrustedRemote($remoteAccess)) { - $this->addTrustedRemote($policy, $remoteAccess); - } - } - - $cspManager->addDefaultPolicy($policy); - } - - private function addTrustedRemote($policy, $url) { - $federationService = \OC::$server->get(FederationService::class); - try { - $remoteCollabora = $federationService->getRemoteCollaboraURL($url); - $policy->addAllowedFrameDomain($url); - $policy->addAllowedFrameDomain($remoteCollabora); - } catch (\Exception $e) { - // We can ignore this exception for adding predefined domains to the CSP as it it would then just - // reload the page to set a proper allowed frame domain if we don't have a fixed list of trusted - // remotes in a global scale scenario - } - } - public function checkAndEnableCODEServer() { // Supported only on Linux OS, and x86_64 & ARM64 platforms $supportedArchs = array('x86_64', 'aarch64'); @@ -295,18 +232,4 @@ class Application extends App implements IBootstrap { $capabilitiesService->refetch(); } } - - /** - * Strips the path and query parameters from the URL. - * - * @param string $url - * @return string - */ - private function domainOnly($url) { - $parsed_url = parse_url($url); - $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; - $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; - $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; - return "$scheme$host$port"; - } } diff --git a/lib/Controller/DirectViewController.php b/lib/Controller/DirectViewController.php index a36ac287..343cf231 100644 --- a/lib/Controller/DirectViewController.php +++ b/lib/Controller/DirectViewController.php @@ -32,7 +32,6 @@ use OCA\Richdocuments\TokenManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; -use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; @@ -45,6 +44,7 @@ use OCP\ILogger; use OCP\IRequest; class DirectViewController extends Controller { + use DocumentTrait; /** @var IRootFolder */ private $rootFolder; @@ -180,10 +180,7 @@ class DirectViewController extends Controller { $this->initialState->provideDocument($wopi); $response = new TemplateResponse('richdocuments', 'documents', $params, 'base'); - $policy = new ContentSecurityPolicy(); - $policy->allowInlineScript(true); - $policy->addAllowedFrameDomain($this->appConfig->getAppValue('public_wopi_url')); - $response->setContentSecurityPolicy($policy); + $this->applyPolicies($response); return $response; } catch (\Exception $e) { $this->logger->logException($e); @@ -236,10 +233,7 @@ class DirectViewController extends Controller { $this->initialState->provideDocument($wopi); $response = new TemplateResponse('richdocuments', 'documents', $params, 'base'); - $policy = new ContentSecurityPolicy(); - $policy->allowInlineScript(true); - $policy->addAllowedFrameDomain($this->appConfig->getAppValue('public_wopi_url')); - $response->setContentSecurityPolicy($policy); + $this->applyPolicies($response); return $response; } } catch (\Exception $e) { diff --git a/lib/Controller/DocumentController.php b/lib/Controller/DocumentController.php index c12df105..399f702e 100644 --- a/lib/Controller/DocumentController.php +++ b/lib/Controller/DocumentController.php @@ -11,7 +11,6 @@ namespace OCA\Richdocuments\Controller; -use OCA\Richdocuments\Events\BeforeFederationRedirectEvent; use OCA\Richdocuments\Service\FederationService; use OCA\Richdocuments\Service\InitialStateService; use OCA\Richdocuments\TemplateManager; @@ -28,8 +27,6 @@ use OCP\Files\NotPermittedException; use \OCP\IRequest; use \OCP\IConfig; use \OCP\ILogger; -use \OCP\AppFramework\Http\ContentSecurityPolicy; -use \OCP\AppFramework\Http\FeaturePolicy; use \OCP\AppFramework\Http\TemplateResponse; use \OCA\Richdocuments\AppConfig; use OCP\ISession; @@ -37,6 +34,7 @@ use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; class DocumentController extends Controller { + use DocumentTrait; /** @var string */ private $uid; @@ -136,37 +134,6 @@ class DocumentController extends Controller { } /** - * Strips the path and query parameters from the URL. - * - * @param string $url - * @return string - */ - private function domainOnly($url) { - $parsed_url = parse_url($url); - $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; - $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; - $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; - return "$scheme$host$port"; - } - - /** - * Setup policy headers for the response - */ - private function setupPolicy($response) { - $wopiDomain = $this->domainOnly($this->appConfig->getAppValue('public_wopi_url')); - - $policy = new ContentSecurityPolicy(); - $policy->addAllowedFrameDomain($wopiDomain); - $policy->allowInlineScript(true); - $policy->addAllowedFormActionDomain($wopiDomain); - $response->setContentSecurityPolicy($policy); - - $featurePolicy = new FeaturePolicy(); - $featurePolicy->addAllowedFullScreenDomain($wopiDomain); - $response->setFeaturePolicy($featurePolicy); - } - - /** * @NoAdminRequired * * @param string $fileId @@ -231,7 +198,7 @@ class DocumentController extends Controller { $this->initialState->provideDocument($wopi); $response = new TemplateResponse('richdocuments', 'documents', $params, 'base'); - $this->setupPolicy($response); + $this->applyPolicies($response); return $response; } catch (\Exception $e) { $this->logger->logException($e, ['app' => 'richdocuments']); @@ -290,7 +257,7 @@ class DocumentController extends Controller { $this->initialState->provideDocument($wopi); $response = new TemplateResponse('richdocuments', 'documents', $params, 'base'); - $this->setupPolicy($response); + $this->applyPolicies($response); return $response; } @@ -349,7 +316,7 @@ class DocumentController extends Controller { $this->initialState->provideDocument($wopi); $response = new TemplateResponse('richdocuments', 'documents', $params, 'base'); - $this->setupPolicy($response); + $this->applyPolicies($response); return $response; } } catch (\Exception $e) { @@ -361,53 +328,6 @@ class DocumentController extends Controller { } /** - * Redirect to the files app with proper CSP headers set for federated editing - * This is a workaround since we cannot set a nonce for allowing dynamic URLs in the richdocument iframe - * - * @NoAdminRequired - * @NoCSRFRequired - */ - public function openRemoteFile($fileId) { - try { - $folder = $this->rootFolder->getUserFolder($this->uid); - $item = $folder->getById($fileId)[0]; - if (!($item instanceof File)) { - throw new \Exception('Node is not a file'); - } - - if ($item->getStorage()->instanceOfStorage(\OCA\Files_Sharing\External\Storage::class)) { - $remote = $item->getStorage()->getRemote(); - $remoteCollabora = $this->federationService->getRemoteCollaboraURL($remote); - if ($remoteCollabora !== '') { - $absolute = $item->getParent()->getPath(); - $relativeFolderPath = $folder->getRelativePath($absolute); - $relativeFilePath = $folder->getRelativePath($item->getPath()); - $url = '/index.php/apps/files/?dir=' . $relativeFolderPath . - '&richdocuments_open=' . $relativeFilePath . - '&richdocuments_fileId=' . $fileId . - '&richdocuments_remote_access=' . $remote; - - $event = new BeforeFederationRedirectEvent( - $item, $relativeFolderPath, $remote - ); - $eventDispatcher = \OC::$server->getEventDispatcher(); - $eventDispatcher->dispatch(BeforeFederationRedirectEvent::class, $event); - if ($event->getRedirectUrl()) { - $url = $event->getRedirectUrl(); - } - return new RedirectResponse($url); - } - $this->logger->warning('Failed to connect to remote collabora instance for ' . $fileId); - } - } catch (\Exception $e) { - $this->logger->logException($e, ['app' => 'richdocuments']); - return $this->renderErrorPage('Failed to open the requested file.'); - } - - return new TemplateResponse('core', '403', [], 'guest'); - } - - /** * Open file on Source instance with token from Initiator instance * * @PublicPage @@ -469,16 +389,7 @@ class DocumentController extends Controller { $this->initialState->provideDocument($wopi); $response = new TemplateResponse('richdocuments', 'documents', $params, 'base'); - $remoteWopi = $this->domainOnly($this->appConfig->getAppValue('wopi_url')); - $policy = new ContentSecurityPolicy(); - $policy->addAllowedFrameDomain($remoteWopi); - $policy->allowInlineScript(true); - $policy->addAllowedFrameAncestorDomain('https://*'); - $response->setContentSecurityPolicy($policy); - $featurePolicy = new FeaturePolicy(); - $featurePolicy->addAllowedFullScreenDomain($remoteWopi); - $response->setFeaturePolicy($featurePolicy); - $response->addHeader('X-Frame-Options', 'ALLOW'); + $this->applyPolicies($response); return $response; } } catch (ShareNotFound $e) { diff --git a/lib/Controller/DocumentTrait.php b/lib/Controller/DocumentTrait.php new file mode 100644 index 00000000..992bbad9 --- /dev/null +++ b/lib/Controller/DocumentTrait.php @@ -0,0 +1,42 @@ +<?php + +namespace OCA\Richdocuments\Controller; + +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\FeaturePolicy; + +trait DocumentTrait { + private $appConfig; + + /** + * Setup policy headers for the response + */ + private function applyPolicies($response) { + $collaboraHost = $this->domainOnly($this->appConfig->getCollaboraUrlPublic()); + + // FIXME We can skip inline source once templates/documents.php is migrated to IInitialState + $policy = new ContentSecurityPolicy(); + $policy->allowInlineScript(true); + $response->setContentSecurityPolicy($policy); + + $featurePolicy = new FeaturePolicy(); + $featurePolicy->addAllowedFullScreenDomain($collaboraHost); + $response->setFeaturePolicy($featurePolicy); + + $response->addHeader('X-Frame-Options', 'ALLOW'); + } + + /** + * Strips the path and query parameters from the URL. + * + * @param string $url + * @return string + */ + private function domainOnly(string $url): string { + $parsed_url = parse_url($url); + $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; + $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; + $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; + return "$scheme$host$port"; + } +} diff --git a/lib/Listener/CSPListener.php b/lib/Listener/CSPListener.php new file mode 100644 index 00000000..33098ef8 --- /dev/null +++ b/lib/Listener/CSPListener.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> + * + * @author Julius Härtl <jus@bitgrid.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCA\Richdocuments\Listener; + +use OCA\Richdocuments\AppConfig; +use OCA\Richdocuments\Service\FederationService; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\EmptyContentSecurityPolicy; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\GlobalScale\IConfig as GlobalScaleConfig; +use OCP\IRequest; +use OCP\Security\CSP\AddContentSecurityPolicyEvent; + +class CSPListener implements IEventListener { + private IRequest $request; + private AppConfig $config; + private IAppManager $appManager; + private FederationService $federationService; + private GlobalScaleConfig $globalScaleConfig; + + public function __construct(IRequest $request, AppConfig $config, IAppManager $appManager, FederationService $federationService, GlobalScaleConfig $globalScaleConfig) { + $this->request = $request; + $this->config = $config; + $this->appManager = $appManager; + $this->federationService = $federationService; + $this->globalScaleConfig = $globalScaleConfig; + } + + public function handle(Event $event): void { + if (!$event instanceof AddContentSecurityPolicyEvent) { + return; + } + + if (!$this->isPageLoad()) { + return; + } + + $urls = array_merge( + [ $this->domainOnly($this->config->getCollaboraUrlPublic()) ], + $this->getFederationDomains(), + $this->getGSDomains() + ); + + $urls = array_filter($urls); + + $policy = new EmptyContentSecurityPolicy(); + foreach ($urls as $url) { + $policy->addAllowedFrameDomain($url); + $policy->addAllowedFormActionDomain($url); + $policy->addAllowedFrameAncestorDomain($url); + $policy->addAllowedImageDomain($url); + } + + $event->addPolicy($policy); + } + + private function isPageLoad(): bool { + $scriptNameParts = explode('/', $this->request->getScriptName()); + return end($scriptNameParts) === 'index.php'; + } + + private function getFederationDomains(): array { + if (!$this->appManager->isEnabledForUser('federation')) { + return []; + } + + $trustedNextcloudDomains = array_filter(array_map(function ($server) { + return $this->federationService->isTrustedRemote($server) ? $server : null; + }, $this->federationService->getTrustedServers())); + + $trustedCollaboraDomains = array_filter(array_map(function ($server) { + try { + return $this->federationService->getRemoteCollaboraURL($server); + } catch (\Exception $e) { + // If there is no remote collabora server we can just skip that + return null; + } + }, $trustedNextcloudDomains)); + + return array_map(function ($url) { + return $this->domainOnly($url); + }, array_merge($trustedNextcloudDomains, $trustedCollaboraDomains)); + } + + private function getGSDomains(): array { + if (!$this->globalScaleConfig->isGlobalScaleEnabled()) { + return []; + } + + return $this->config->getGlobalScaleTrustedHosts(); + } + + /** + * Strips the path and query parameters from the URL. + */ + private function domainOnly(string $url): string { + $parsedUrl = parse_url($url); + $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : ''; + $host = $parsedUrl['host'] ?? ''; + $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; + return "$scheme$host$port"; + } +} diff --git a/lib/Service/FederationService.php b/lib/Service/FederationService.php index 403df2c1..5d00c5e6 100644 --- a/lib/Service/FederationService.php +++ b/lib/Service/FederationService.php @@ -1,4 +1,7 @@ <?php + +declare(strict_types=1); + /** * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> * @@ -29,7 +32,7 @@ use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Db\Direct; use OCA\Richdocuments\Db\Wopi; use OCA\Richdocuments\TokenManager; -use OCP\AppFramework\QueryException; +use OCP\AutoloadNotAllowedException; use OCP\Files\File; use OCP\Files\InvalidPathException; use OCP\Files\NotFoundException; @@ -40,6 +43,8 @@ use OCP\ILogger; use OCP\IRequest; use OCP\IURLGenerator; use OCP\Share\IShare; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; class FederationService { @@ -69,9 +74,21 @@ class FederationService { $this->request = $request; $this->urlGenerator = $urlGenerator; try { - $this->trustedServers = \OC::$server->query(\OCA\Federation\TrustedServers::class); - } catch (QueryException $e) { + $this->trustedServers = \OC::$server->get(\OCA\Federation\TrustedServers::class); + } catch (NotFoundExceptionInterface $e) { + } catch (ContainerExceptionInterface $e) { + } catch (AutoloadNotAllowedException $e) { + } + } + + public function getTrustedServers(): array { + if (!$this->trustedServers) { + return []; } + + return array_map(function (array $server) { + return $server['url']; + }, $this->trustedServers->getServers()); } /** @@ -118,7 +135,7 @@ class FederationService { $domain = $this->getDomainWithoutPort($domainWithPort); - $trustedList = array_merge($this->appConfig->getTrustedDomains(), [$this->request->getServerHost()]); + $trustedList = array_merge($this->appConfig->getGlobalScaleTrustedHosts(), [$this->request->getServerHost()]); if (!is_array($trustedList)) { return false; } diff --git a/src/files.js b/src/files.js index 932381f7..e9d7e359 100644 --- a/src/files.js +++ b/src/files.js @@ -7,7 +7,6 @@ import { showError } from '@nextcloud/dialogs' import { getDocumentUrlFromTemplate, getDocumentUrlForPublicFile, getDocumentUrlForFile } from './helpers/url' import PostMessageService from './services/postMessage.tsx' import Config from './services/config.tsx' -import Preload from './services/preload' import Types from './helpers/types' import FilesAppIntegration from './view/FilesAppIntegration' import { splitPath } from './helpers' @@ -151,52 +150,6 @@ const odfViewer = { documentUrl = getDocumentUrlFromTemplate(templateId, fileName, fileDir) } - /** - * We need to reload the page to set a proper CSP if the file is federated - * and the reload didn't happen for the exact same file - * - * @param {string} url the url - * @param {Function} callback to be run after reload is complete - */ - const canAccessCSP = (url, callback) => { - let canEmbed = false - const frame = document.createElement('iframe') - frame.style.display = 'none' - frame.onload = () => { - canEmbed = true - } - document.body.appendChild(frame) - frame.setAttribute('src', url) - setTimeout(() => { - if (!canEmbed) { - callback() - } - document.body.removeChild(frame) - }, 1000) - - } - - // FIXME: Once Nextcloud 16 is minimum requirement we can just pass the allowed domains to initial state - // to check then if they are set properly - const reloadForFederationCSP = (fileName, shareOwnerId) => { - const preloadId = Preload.open ? parseInt(Preload.open.id) : -1 - if (typeof shareOwnerId !== 'undefined') { - const lastIndex = shareOwnerId.lastIndexOf('@') - // only redirect if remote file, not opened though reload and csp blocks the request - if (shareOwnerId.slice(lastIndex).indexOf('/') !== -1 && fileId !== preloadId) { - canAccessCSP('https://' + shareOwnerId.slice(lastIndex) + '/ocs/v2.php/apps/richdocuments/api/v1/federation', () => { - console.debug('Cannot load federated instance though CSP, navigating to ', generateUrl('/apps/richdocuments/open?fileId=' + fileId)) - window.location = generateUrl('/apps/richdocuments/open?fileId=' + fileId) - }) - } - } - return false - } - - if (context) { - reloadForFederationCSP(fileName, context?.shareOwnerId) - } - $('head').append($('<link rel="stylesheet" type="text/css" href="' + generateFilePath('richdocuments', 'css', 'mobile.css') + '"/>')) const $iframe = $('<iframe id="richdocumentsframe" nonce="' + btoa(OC.requestToken) + '" scrolling="no" allowfullscreen src="' + documentUrl + '" />') @@ -361,15 +314,6 @@ $(document).ready(function() { OC.MimeType._mimeTypeIcons['application/vnd.oasis.opendocument.graphics'] = imagePath('richdocuments', 'x-office-draw') - // Open the template picker if there was a create parameter detected on load - if (Preload.create && Preload.create.type && Preload.create.filename) { - FilesAppIntegration.preloadCreate() - } - - if (Preload.open) { - FilesAppIntegration.preloadOpen() - } - // Open documents if a public page is opened for a supported mimetype const isSupportedMime = isPublic && odfViewer.supportedMimes.indexOf($('#mimetype').val()) !== -1 && odfViewer.excludeMimeFromDefaultOpen.indexOf($('#mimetype').val()) === -1 const showSecureView = isPublic && isDownloadHidden && odfViewer.hideDownloadMimes.indexOf($('#mimetype').val()) !== -1 diff --git a/src/services/preload.js b/src/services/preload.js deleted file mode 100644 index c0cf1e1c..00000000 --- a/src/services/preload.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { getSearchParam } from '../helpers/url' - -const preloadCreate = getSearchParam('richdocuments_create') -const preloadOpen = getSearchParam('richdocuments_open') -const Preload = {} - -if (preloadCreate) { - Preload.create = { - type: getSearchParam('richdocuments_create'), - filename: getSearchParam('richdocuments_filename'), - } -} - -if (preloadOpen) { - Preload.open = { - filename: preloadOpen, - id: getSearchParam('richdocuments_fileId'), - } -} - -export default Preload diff --git a/src/view/FilesAppIntegration.js b/src/view/FilesAppIntegration.js index 66b7c384..a634ac83 100644 --- a/src/view/FilesAppIntegration.js +++ b/src/view/FilesAppIntegration.js @@ -23,11 +23,6 @@ import { generateUrl, generateRemoteUrl, getRootUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' import moment from '@nextcloud/moment' -import Preload from '../services/preload' -import { splitPath } from '../helpers' -import Types from '../helpers/types' -import Config from '../services/config.tsx' -import NewFileMenu from './NewFileMenu' const isPublic = document.getElementById('isPublic') && document.getElementById('isPublic').value === '1' @@ -570,41 +565,6 @@ export default { }) }, - /** - * Automaically open a document on page load - */ - preloadOpen() { - if (this.handlers.preloadOpen && this.handlers.preloadOpen(this)) { - return - } - - const fileId = Preload.open.id - const path = Preload.open.filename - setTimeout(function() { - window.FileList.$fileList.one('updated', function() { - const [, file] = splitPath(path) - const fileModel = FileList.getModelForFile(file) - OCA.RichDocuments.open({ path, fileId, fileModel, fileList: window.FileList }) - }) - }, 250) - }, - - /** - * Automaically open a template picker on page load - */ - preloadCreate() { - if (this.handlers.preloadCreate && this.handlers.preloadCreate(this)) { - return - } - - setTimeout(function() { - window.FileList.$fileList.one('updated', function() { - const fileType = Types.getFileType(Preload.create.type, Config.get('ooxml')) - NewFileMenu._openTemplatePicker(Preload.create.type, fileType.mime, Preload.create.filename + '.' + fileType.extension) - }) - }, 250) - }, - loggingContext() { return { currentUser: getCurrentUser()?.uid, diff --git a/tests/lib/Listener/CSPListenerTest.php b/tests/lib/Listener/CSPListenerTest.php new file mode 100644 index 00000000..32d205e2 --- /dev/null +++ b/tests/lib/Listener/CSPListenerTest.php @@ -0,0 +1,211 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> + * + * @author Julius Härtl <jus@bitgrid.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use OC\Security\CSP\ContentSecurityPolicyManager; +use OCA\Richdocuments\AppConfig; +use OCA\Richdocuments\Listener\CSPListener; +use OCA\Richdocuments\Service\FederationService; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\EmptyContentSecurityPolicy; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\GlobalScale\IConfig as GlobalScaleConfig; +use OCP\IRequest; +use OCP\Security\CSP\AddContentSecurityPolicyEvent; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class CSPListenerTest extends TestCase { + + /** @var IRequest|MockObject */ + private $request; + /** @var AppConfig|MockObject */ + private $config; + /** @var IAppManager|MockObject */ + private $appManager; + /** @var GlobalScaleConfig|MockObject */ + private $gsConfig; + /** @var FederationService|MockObject */ + private $federationService; + private CSPListener $listener; + + public function setUp(): void { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->config = $this->createMock(AppConfig::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->gsConfig = $this->createMock(GlobalScaleConfig::class); + $this->federationService = $this->createMock(FederationService::class); + + $this->listener = new CSPListener( + $this->request, + $this->config, + $this->appManager, + $this->federationService, + $this->gsConfig + ); + } + + private function getMergedPolicy(): ContentSecurityPolicy { + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects(self::once()) + ->method('dispatchTyped') + ->willReturnCallback(function ($event) { + $this->listener->handle($event); + }); + $manager = new ContentSecurityPolicyManager($eventDispatcher); + + return $manager->getDefaultPolicy(); + } + + private function expectPageLoad(): void { + $this->request->expects(self::once()) + ->method('getScriptName') + ->willReturn('index.php'); + } + + public function testHandle() { + $this->expectPageLoad(); + $this->config->expects(self::any()) + ->method('getCollaboraUrlPublic') + ->willReturn('http://public'); + + $policy = $this->getMergedPolicy(); + + self::assertEquals(["http://public"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "http://public"], $policy->getAllowedFormActionDomains()); + } + + public function testHandleRemote() { + $manager = $this->createMock(ContentSecurityPolicyManager::class); + $event = new AddContentSecurityPolicyEvent( + $manager + ); + + $this->request->expects(self::once()) + ->method('getScriptName') + ->willReturn('remote.php'); + + $manager->expects(self::never()) + ->method('addDefaultPolicy'); + $this->listener->handle($event); + } + + public function testNotSetup() { + $this->expectPageLoad(); + $this->config->expects(self::any()) + ->method('getCollaboraUrlPublic') + ->willReturn(''); + + $policy = $this->getMergedPolicy(); + + self::assertEquals([], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'"], $policy->getAllowedFormActionDomains()); + } + + public function testWopiUrlPublic() { + $this->expectPageLoad(); + $this->config->expects(self::any()) + ->method('getCollaboraUrlPublic') + ->willReturn('http://public'); + + $policy = $this->getMergedPolicy(); + + self::assertEquals(["http://public"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "http://public"], $policy->getAllowedFormActionDomains()); + } + + public function testWopiUrl() { + $this->expectPageLoad(); + $this->config->expects(self::any()) + ->method('getCollaboraUrlPublic') + ->willReturn('https://public/collabora/'); + + $policy = $this->getMergedPolicy(); + + self::assertEquals(["https://public"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "https://public"], $policy->getAllowedFormActionDomains()); + } + + public function testGS() { + $this->expectPageLoad(); + $this->config->expects(self::any()) + ->method('getCollaboraUrlPublic') + ->willReturn('https://public/collabora/'); + $this->gsConfig->expects(self::any()) + ->method('isGlobalScaleEnabled') + ->willReturn(true); + $this->config->expects(self::any()) + ->method('getGlobalScaleTrustedHosts') + ->willReturn(['*.example.com']); + + $policy = $this->getMergedPolicy(); + + self::assertEquals(["https://public", "*.example.com"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "https://public", "*.example.com"], $policy->getAllowedFormActionDomains()); + } + + public function testNoGS() { + $this->expectPageLoad(); + $this->config->expects(self::any()) + ->method('getCollaboraUrlPublic') + ->willReturn('http://internal'); + $this->gsConfig->expects(self::any()) + ->method('isGlobalScaleEnabled') + ->willReturn(false); + + $policy = $this->getMergedPolicy(); + + self::assertEquals(["http://internal"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "http://internal"], $policy->getAllowedFormActionDomains()); + } + + public function testHandleMerged() { + $this->expectPageLoad(); + $this->config->expects(self::any()) + ->method('getCollaboraUrlPublic') + ->willReturn('http://public'); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects(self::once()) + ->method('dispatchTyped') + ->willReturnCallback(function (AddContentSecurityPolicyEvent $event) { + $otherPolicy = new EmptyContentSecurityPolicy(); + $otherPolicy->addAllowedFrameDomain('external.example.com'); + $otherPolicy->addAllowedFormActionDomain('external.example.com'); + $event->addPolicy($otherPolicy); + + $this->listener->handle($event); + }); + $manager = new ContentSecurityPolicyManager($eventDispatcher); + + $policy = $manager->getDefaultPolicy(); + + self::assertEquals(["external.example.com", "http://public"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "external.example.com", "http://public"], $policy->getAllowedFormActionDomains()); + } +} |