diff options
author | Julius Härtl <jus@bitgrid.net> | 2022-01-13 11:25:40 +0300 |
---|---|---|
committer | Julius Härtl <jus@bitgrid.net> | 2022-01-13 11:25:40 +0300 |
commit | 148e4bd14d2b6827935be08338f540d1f93c62c2 (patch) | |
tree | 519ea6022496d06df0272daaeff2c3a0be7bfd3e | |
parent | 3e4ef0fd749ffc97d9ee104064b5bad52e013cdf (diff) |
Implement a storage wrapper for properly handling the secure view featuresenh/secure-view
Signed-off-by: Julius Härtl <jus@bitgrid.net>
-rw-r--r-- | lib/AppInfo/Application.php | 40 | ||||
-rw-r--r-- | lib/Flow/Operation.php | 277 | ||||
-rw-r--r-- | lib/Flow/StorageWrapper.php | 330 |
3 files changed, 647 insertions, 0 deletions
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1594b53f..9cb3c09e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,11 +25,15 @@ namespace OCA\Richdocuments\AppInfo; use OC\EventDispatcher\SymfonyAdapter; +use OC\Files\Filesystem; use OC\Files\Type\Detection; use OC\Security\CSP\ContentSecurityPolicy; use OCA\Files_Sharing\Listener\LoadAdditionalListener; +use OCA\Files_Sharing\SharedStorage; +use OCA\Richdocuments\Flow\Operation; use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Capabilities; +use OCA\Richdocuments\Flow\StorageWrapper; use OCA\Richdocuments\Middleware\WOPIMiddleware; use OCA\Richdocuments\PermissionManager; use OCA\Richdocuments\Preview\MSExcel; @@ -43,16 +47,21 @@ use OCA\Richdocuments\Service\InitialStateService; use OCA\Richdocuments\Template\CollaboraTemplateProvider; use OCA\Richdocuments\WOPI\DiscoveryManager; use OCA\Viewer\Event\LoadViewer; +use OCA\WorkflowEngine\Manager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Storage\IStorage; use OCP\Files\Template\ITemplateManager; use OCP\Files\Template\TemplateFileCreator; use OCP\IConfig; use OCP\IL10N; use OCP\IPreview; +use OCP\Util; +use OCP\WorkflowEngine\Events\RegisterOperationsEvent; +use OCP\WorkflowEngine\IManager; class Application extends App implements IBootstrap { @@ -68,6 +77,8 @@ class Application extends App implements IBootstrap { $context->registerTemplateProvider(CollaboraTemplateProvider::class); $context->registerCapability(Capabilities::class); $context->registerMiddleWare(WOPIMiddleware::class); + + Util::connectHook('OC_Filesystem', 'preSetup', $this, 'addStorageWrapper'); } public function boot(IBootContext $context): void { @@ -139,6 +150,11 @@ class Application extends App implements IBootstrap { }); $context->injectFn(function (SymfonyAdapter $symfonyAdapter, IEventDispatcher $eventDispatcher, InitialStateService $initialStateService) { + // TODO: TO listener + $eventDispatcher->addListener(RegisterOperationsEvent::class, function ($event) { + /** @var RegisterOperationsEvent $event */ + $event->registerOperation(\OC::$server->get(Operation::class)); + }); $eventDispatcher->addListener(LoadViewer::class, function () use ($initialStateService) { $initialStateService->provideCapabilities(); \OCP\Util::addScript('richdocuments', 'richdocuments-viewer', 'viewer'); @@ -307,4 +323,28 @@ class Application extends App implements IBootstrap { $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; return "$scheme$host$port"; } + + /** + * @internal + * @param $mountPoint + * @param IStorage $storage + * @return StorageWrapper|IStorage + */ + public function addStorageWrapperCallback($mountPoint, IStorage $storage) { + if (!\OC::$CLI && !$storage->instanceOfStorage(SharedStorage::class)) { + /** @var Operation $operation */ + $operation = $this->getContainer()->get(Operation::class); + return new StorageWrapper([ + 'storage' => $storage, + 'mountPoint' => $mountPoint, + 'operation' => $operation, + ]); + } + + return $storage; + } + + public function addStorageWrapper() { + Filesystem::addStorageWrapper('office_secureview', [$this, 'addStorageWrapperCallback'], -10); + } } diff --git a/lib/Flow/Operation.php b/lib/Flow/Operation.php new file mode 100644 index 00000000..847b188d --- /dev/null +++ b/lib/Flow/Operation.php @@ -0,0 +1,277 @@ +<?php +/* + * @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/>. + * + */ + +declare(strict_types=1); + +namespace OCA\Richdocuments\Flow; + + +use Exception; +use OCA\FilesAccessControl\StorageWrapper; +use OCA\WorkflowEngine\Entity\File; +use OCP\EventDispatcher\Event; +use OCP\Files\ForbiddenException; +use OCP\Files\Storage\IStorage; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\WorkflowEngine\IComplexOperation; +use OCP\WorkflowEngine\IManager; +use OCP\WorkflowEngine\IRuleMatcher; +use OCP\WorkflowEngine\ISpecificOperation; +use ReflectionClass; +use UnexpectedValueException; + +class Operation implements IComplexOperation, ISpecificOperation { + /** @var IManager */ + protected $manager; + + /** @var IL10N */ + protected $l; + + /** @var IURLGenerator */ + protected $urlGenerator; + + /** @var int */ + protected $nestingLevel = 0; + + /** + * @param IManager $manager + * @param IL10N $l + */ + public function __construct(IManager $manager, IL10N $l, IURLGenerator $urlGenerator) { + $this->manager = $manager; + $this->l = $l; + $this->urlGenerator = $urlGenerator; + } + + /** + * @param IStorage $storage + * @param string $path + * @param bool $isDir + * @throws ForbiddenException + */ + public function checkFileAccess(IStorage $storage, string $path, bool $isDir = false): void { + if (!$this->isBlockablePath($storage, $path) || $this->isCreatingSkeletonFiles() || $this->nestingLevel !== 0) { + // Allow creating skeletons and theming + // https://github.com/nextcloud/files_accesscontrol/issues/5 + // https://github.com/nextcloud/files_accesscontrol/issues/12 + return; + } + + $this->nestingLevel++; + + $filePath = $this->translatePath($storage, $path); + $ruleMatcher = $this->manager->getRuleMatcher(); + $ruleMatcher->setFileInfo($storage, $filePath, $isDir); + $ruleMatcher->setOperation($this); + $match = $ruleMatcher->getFlows(); + + $this->nestingLevel--; + + if (!empty($match)) { + // TODO: check if mimetype supported + // TODO: check to only return if either index or wopi token used (maybe catched in middleware) + if (strpos(\OC::$server->getRequest()->getRequestUri(), '/wopi/files/') > 0 + || strpos(\OC::$server->getRequest()->getRequestUri(), '/apps/richdocuments/') > 0) { + return; + } + // All Checks of one operation matched: prevent access + throw new ForbiddenException('Access denied', false); + } + } + + protected function isBlockablePath(IStorage $storage, string $path): bool { + if (property_exists($storage, 'mountPoint')) { + $hasMountPoint = $storage instanceof StorageWrapper; + if (!$hasMountPoint) { + $ref = new ReflectionClass($storage); + $prop = $ref->getProperty('mountPoint'); + $hasMountPoint = $prop->isPublic(); + } + + if ($hasMountPoint) { + /** @var StorageWrapper $storage */ + $fullPath = $storage->mountPoint . ltrim($path, '/'); + } else { + $fullPath = $path; + } + } else { + $fullPath = $path; + } + + if (substr_count($fullPath, '/') < 3) { + return false; + } + + // '', admin, 'files', 'path/to/file.txt' + $segment = explode('/', $fullPath, 4); + + if (isset($segment[2]) && $segment[1] === '__groupfolders' && $segment[2] === 'trash') { + // Special case, a file was deleted inside a groupfolder + return true; + } + + return isset($segment[2]) && in_array($segment[2], [ + 'files', + 'thumbnails', + 'files_versions', + ]); + } + + /** + * For thumbnails and versions we want to check the tags of the original file + */ + protected function translatePath(IStorage $storage, string $path): string { + if (substr_count($path, '/') < 1) { + return $path; + } + + // 'files', 'path/to/file.txt' + [$folder, $innerPath] = explode('/', $path, 2); + + if ($folder === 'files_versions') { + $innerPath = substr($innerPath, 0, strrpos($innerPath, '.v')); + return 'files/' . $innerPath; + } elseif ($folder === 'thumbnails') { + [$fileId,] = explode('/', $innerPath, 2); + $innerPath = $storage->getCache()->getPathById($fileId); + + if ($innerPath !== null) { + return 'files/' . $innerPath; + } + } + + return $path; + } + + /** + * Check if we are in the LoginController and if so, ignore the firewall + */ + protected function isCreatingSkeletonFiles(): bool { + $exception = new Exception(); + $trace = $exception->getTrace(); + + foreach ($trace as $step) { + if (isset($step['class']) && $step['class'] === 'OC\Core\Controller\LoginController' && + isset($step['function']) && $step['function'] === 'tryLogin') { + return true; + } + } + + return false; + } + + /** + * @param string $name + * @param array[] $checks + * @param string $operation + * @throws UnexpectedValueException + */ + public function validateOperation(string $name, array $checks, string $operation): void { + if (empty($checks)) { + throw new UnexpectedValueException($this->l->t('No rule given')); + } + } + + /** + * returns a translated name to be presented in the web interface + * + * Example: "Automated tagging" (en), "Aŭtomata etikedado" (eo) + * + * @since 18.0.0 + */ + public function getDisplayName(): string { + return $this->l->t('Secure access to a file'); + } + + /** + * returns a translated, descriptive text to be presented in the web interface. + * + * It should be short and precise. + * + * Example: "Tag based automatic deletion of files after a given time." (en) + * + * @since 18.0.0 + */ + public function getDescription(): string { + return ''; + } + + /** + * returns the URL to the icon of the operator for display in the web interface. + * + * Usually, the implementation would utilize the `imagePath()` method of the + * `\OCP\IURLGenerator` instance and simply return its result. + * + * Example implementation: return $this->urlGenerator->imagePath('myApp', 'cat.svg'); + * + * @since 18.0.0 + */ + public function getIcon(): string { + return $this->urlGenerator->imagePath('richdocuments', 'app.svg'); + } + + /** + * returns whether the operation can be used in the requested scope. + * + * Scope IDs are defined as constants in OCP\WorkflowEngine\IManager. At + * time of writing these are SCOPE_ADMIN and SCOPE_USER. + * + * For possibly unknown future scopes the recommended behaviour is: if + * user scope is permitted, the default behaviour should return `true`, + * otherwise `false`. + * + * @since 18.0.0 + */ + public function isAvailableForScope(int $scope): bool { + return $scope === IManager::SCOPE_ADMIN; + } + + /** + * returns the id of the entity the operator is designed for + * + * Example: 'WorkflowEngine_Entity_File' + * + * @since 18.0.0 + */ + public function getEntityId(): string { + return File::class; + } + + /** + * As IComplexOperation chooses the triggering events itself, a hint has + * to be shown to the user so make clear when this operation is becoming + * active. This method returns such a translated string. + * + * Example: "When a file is accessed" (en) + * + * @since 18.0.0 + */ + public function getTriggerHint(): string { + return $this->l->t('File is accessed'); + } + + public function onEvent(string $eventName, Event $event, IRuleMatcher $ruleMatcher): void { + // Noop + } +} diff --git a/lib/Flow/StorageWrapper.php b/lib/Flow/StorageWrapper.php new file mode 100644 index 00000000..aa9fbc73 --- /dev/null +++ b/lib/Flow/StorageWrapper.php @@ -0,0 +1,330 @@ +<?php +/* + * @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/>. + * + */ + +declare(strict_types=1); + +namespace OCA\Richdocuments\Flow; + +use OC\Files\Cache\Cache; +use OC\Files\Storage\Storage; +use OC\Files\Storage\Wrapper\Wrapper; +use OCP\Constants; +use OCP\Files\ForbiddenException; +use OCP\Files\Storage\IStorage; +use OCP\Files\Storage\IWriteStreamStorage; + +class StorageWrapper extends Wrapper implements IWriteStreamStorage { + + /** @var Operation */ + protected $operation; + + /** @var string */ + public $mountPoint; + /** @var int */ + protected $mask; + + /** + * @param array $parameters + */ + public function __construct($parameters) { + parent::__construct($parameters); + $this->operation = $parameters['operation']; + $this->mountPoint = $parameters['mountPoint']; + + $this->mask = Constants::PERMISSION_ALL; + $this->mask &= ~Constants::PERMISSION_READ; + $this->mask &= ~Constants::PERMISSION_CREATE; + $this->mask &= ~Constants::PERMISSION_UPDATE; + $this->mask &= ~Constants::PERMISSION_DELETE; + } + + /** + * @throws ForbiddenException + */ + protected function checkFileAccess(string $path, bool $isDir = false): void { + $this->operation->checkFileAccess($this, $path, $isDir); + } + + /* + * Storage wrapper methods + */ + + /** + * see http://php.net/manual/en/function.mkdir.php + * + * @param string $path + * @return bool + * @throws ForbiddenException + */ + public function mkdir($path) { + $this->checkFileAccess($path, true); + return $this->storage->mkdir($path); + } + + /** + * see http://php.net/manual/en/function.rmdir.php + * + * @param string $path + * @return bool + * @throws ForbiddenException + */ + public function rmdir($path) { + $this->checkFileAccess($path, true); + return $this->storage->rmdir($path); + } + + /** + * check if a file can be created in $path + * + * @param string $path + * @return bool + */ + public function isCreatable($path) { + try { + $this->checkFileAccess($path); + } catch (ForbiddenException $e) { + return false; + } + return $this->storage->isCreatable($path); + } + + /** + * check if a file can be read + * + * @param string $path + * @return bool + */ + public function isReadable($path) { + return true; + try { + $this->checkFileAccess($path); + } catch (ForbiddenException $e) { + return false; + } + return $this->storage->isReadable($path); + } + + /** + * check if a file can be written to + * + * @param string $path + * @return bool + */ + public function isUpdatable($path) { + try { + $this->checkFileAccess($path); + } catch (ForbiddenException $e) { + return false; + } + return $this->storage->isUpdatable($path); + } + + /** + * check if a file can be deleted + * + * @param string $path + * @return bool + */ + public function isDeletable($path) { + try { + $this->checkFileAccess($path); + } catch (ForbiddenException $e) { + return false; + } + return $this->storage->isDeletable($path); + } + + public function getPermissions($path) { + try { + $this->checkFileAccess($path); + } catch (ForbiddenException $e) { + return $this->mask; + } + return $this->storage->getPermissions($path); + } + + /** + * see http://php.net/manual/en/function.file_get_contents.php + * + * @param string $path + * @return string + * @throws ForbiddenException + */ + public function file_get_contents($path) { + $this->checkFileAccess($path); + return $this->storage->file_get_contents($path); + } + + /** + * see http://php.net/manual/en/function.file_put_contents.php + * + * @param string $path + * @param string $data + * @return bool + * @throws ForbiddenException + */ + public function file_put_contents($path, $data) { + $this->checkFileAccess($path); + return $this->storage->file_put_contents($path, $data); + } + + /** + * see http://php.net/manual/en/function.unlink.php + * + * @param string $path + * @return bool + * @throws ForbiddenException + */ + public function unlink($path) { + $this->checkFileAccess($path); + return $this->storage->unlink($path); + } + + /** + * see http://php.net/manual/en/function.rename.php + * + * @param string $path1 + * @param string $path2 + * @return bool + * @throws ForbiddenException + */ + public function rename($path1, $path2) { + $this->checkFileAccess($path1); + $this->checkFileAccess($path2); + return $this->storage->rename($path1, $path2); + } + + /** + * see http://php.net/manual/en/function.copy.php + * + * @param string $path1 + * @param string $path2 + * @return bool + * @throws ForbiddenException + */ + public function copy($path1, $path2) { + $this->checkFileAccess($path1); + $this->checkFileAccess($path2); + return $this->storage->copy($path1, $path2); + } + + /** + * see http://php.net/manual/en/function.fopen.php + * + * @param string $path + * @param string $mode + * @return resource + * @throws ForbiddenException + */ + public function fopen($path, $mode) { + $this->checkFileAccess($path); + return $this->storage->fopen($path, $mode); + } + + /** + * see http://php.net/manual/en/function.touch.php + * If the backend does not support the operation, false should be returned + * + * @param string $path + * @param int $mtime + * @return bool + * @throws ForbiddenException + */ + public function touch($path, $mtime = null) { + $this->checkFileAccess($path); + return $this->storage->touch($path, $mtime); + } + + /** + * get a cache instance for the storage + * + * @param string $path + * @param Storage (optional) the storage to pass to the cache + * @return Cache + */ + public function getCache($path = '', $storage = null) { + return parent::getCache($path, $storage); + } + + /** + * A custom storage implementation can return an url for direct download of a give file. + * + * For now the returned array can hold the parameter url - in future more attributes might follow. + * + * @param string $path + * @return array + * @throws ForbiddenException + */ + public function getDirectDownload($path) { + $this->checkFileAccess($path); + return $this->storage->getDirectDownload($path); + } + + /** + * @param IStorage $sourceStorage + * @param string $sourceInternalPath + * @param string $targetInternalPath + * @return bool + * @throws ForbiddenException + */ + public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + if ($sourceStorage === $this) { + return $this->copy($sourceInternalPath, $targetInternalPath); + } + + $this->checkFileAccess($targetInternalPath); + return $this->storage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + + /** + * @param IStorage $sourceStorage + * @param string $sourceInternalPath + * @param string $targetInternalPath + * @return bool + * @throws ForbiddenException + */ + public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + if ($sourceStorage === $this) { + return $this->rename($sourceInternalPath, $targetInternalPath); + } + + $this->checkFileAccess($targetInternalPath); + return $this->storage->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + + /** + * @throws ForbiddenException + */ + public function writeStream(string $path, $stream, int $size = null): int { + // Required for object storage since part file is not in the storage so we cannot check it before moving it to the storage + // As an alternative we might be able to check on the cache update/insert/delete though the Cache wrapper + $result = $this->storage->writeStream($path, $stream, $size); + try { + $this->checkFileAccess($path); + } catch (\Exception $e) { + $this->storage->unlink($path); + throw $e; + } + return $result; + } +} |