Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/richdocuments.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulius Härtl <jus@bitgrid.net>2022-01-13 11:25:40 +0300
committerJulius Härtl <jus@bitgrid.net>2022-01-13 11:25:40 +0300
commit148e4bd14d2b6827935be08338f540d1f93c62c2 (patch)
tree519ea6022496d06df0272daaeff2c3a0be7bfd3e
parent3e4ef0fd749ffc97d9ee104064b5bad52e013cdf (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.php40
-rw-r--r--lib/Flow/Operation.php277
-rw-r--r--lib/Flow/StorageWrapper.php330
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;
+ }
+}