Skip to content

Commit 9e4933a

Browse files
committed
feat: Add storage wrappert to block download on secure view
Signed-off-by: Julius Knorr <jus@bitgrid.net>
1 parent 64812a6 commit 9e4933a

11 files changed

Lines changed: 296 additions & 20 deletions

appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ You can also edit your documents off-line with the Collabora Office app from the
1515
<licence>agpl</licence>
1616
<author>Collabora Productivity based on work of Frank Karlitschek, Victor Dubiniuk</author>
1717
<types>
18+
<filesystem />
1819
<prevent_group_restriction/>
1920
</types>
2021
<documentation>

lib/AppConfig.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ public function useSecureViewAdditionalMimes(): bool {
202202
return $this->config->getAppValue(Application::APPNAME, self::USE_SECURE_VIEW_ADDITIONAL_MIMES, 'no') === 'yes';
203203
}
204204

205+
public function getMimeTypes(): array {
206+
return array_merge(
207+
Capabilities::MIMETYPES,
208+
Capabilities::MIMETYPES_MSOFFICE,
209+
);
210+
}
211+
205212
public function getDomainList(): array {
206213
$urls = array_merge(
207214
[ $this->domainOnly($this->getCollaboraUrlPublic()) ],

lib/AppInfo/Application.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
namespace OCA\Richdocuments\AppInfo;
1010

1111
use OCA\Files_Sharing\Event\ShareLinkAccessedEvent;
12+
use OCA\Richdocuments\AppConfig;
1213
use OCA\Richdocuments\Capabilities;
1314
use OCA\Richdocuments\Conversion\ConversionProvider;
1415
use OCA\Richdocuments\Db\WopiMapper;
@@ -20,6 +21,7 @@
2021
use OCA\Richdocuments\Listener\FileCreatedFromTemplateListener;
2122
use OCA\Richdocuments\Listener\LoadAdditionalListener;
2223
use OCA\Richdocuments\Listener\LoadViewerListener;
24+
use OCA\Richdocuments\Listener\OverwritePublicSharePropertiesListener;
2325
use OCA\Richdocuments\Listener\ReferenceListener;
2426
use OCA\Richdocuments\Listener\RegisterTemplateFileCreatorListener;
2527
use OCA\Richdocuments\Listener\ShareLinkListener;
@@ -32,7 +34,9 @@
3234
use OCA\Richdocuments\Preview\OpenDocument;
3335
use OCA\Richdocuments\Preview\Pdf;
3436
use OCA\Richdocuments\Reference\OfficeTargetReferenceProvider;
37+
use OCA\Richdocuments\Storage\SecureViewWrapper;
3538
use OCA\Richdocuments\Template\CollaboraTemplateProvider;
39+
use OCA\Talk\Events\OverwritePublicSharePropertiesEvent;
3640
use OCA\Viewer\Event\LoadViewer;
3741
use OCP\AppFramework\App;
3842
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -41,12 +45,15 @@
4145
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
4246
use OCP\Collaboration\Reference\RenderReferenceEvent;
4347
use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent;
48+
use OCP\Files\Storage\IStorage;
4449
use OCP\Files\Template\BeforeGetTemplatesEvent;
4550
use OCP\Files\Template\FileCreatedFromTemplateEvent;
4651
use OCP\Files\Template\RegisterTemplateCreatorEvent;
52+
use OCP\IAppConfig;
4753
use OCP\Preview\BeforePreviewFetchedEvent;
4854
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
4955
use OCP\Security\FeaturePolicy\AddFeaturePolicyEvent;
56+
use OCP\Server;
5057

5158
class Application extends App implements IBootstrap {
5259
public const APPNAME = 'richdocuments';
@@ -56,6 +63,8 @@ public function __construct(array $urlParams = []) {
5663
}
5764

5865
public function register(IRegistrationContext $context): void {
66+
\OCP\Util::connectHook('OC_Filesystem', 'preSetup', $this, 'addStorageWrapper');
67+
5968
$context->registerTemplateProvider(CollaboraTemplateProvider::class);
6069
$context->registerCapability(Capabilities::class);
6170
$context->registerMiddleWare(WOPIMiddleware::class);
@@ -70,6 +79,7 @@ public function register(IRegistrationContext $context): void {
7079
$context->registerEventListener(RenderReferenceEvent::class, ReferenceListener::class);
7180
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
7281
$context->registerEventListener(BeforeGetTemplatesEvent::class, BeforeGetTemplatesListener::class);
82+
$context->registerEventListener(OverwritePublicSharePropertiesEvent::class, OverwritePublicSharePropertiesListener::class);
7383
$context->registerReferenceProvider(OfficeTargetReferenceProvider::class);
7484
$context->registerSensitiveMethods(WopiMapper::class, [
7585
'getPathForToken',
@@ -88,4 +98,32 @@ public function register(IRegistrationContext $context): void {
8898

8999
public function boot(IBootContext $context): void {
90100
}
101+
102+
/**
103+
* @internal
104+
*/
105+
public function addStorageWrapper(): void {
106+
if (Server::get(IAppConfig::class)->getValueString(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_enabled', 'no') === 'no') {
107+
return;
108+
}
109+
110+
\OC\Files\Filesystem::addStorageWrapper('richdocuments', [$this, 'addStorageWrapperCallback'], -10);
111+
}
112+
113+
/**
114+
* @param $mountPoint
115+
* @param IStorage $storage
116+
* @return SecureViewWrapper|IStorage
117+
*@internal
118+
*/
119+
public function addStorageWrapperCallback($mountPoint, IStorage $storage) {
120+
if (!\OC::$CLI && $mountPoint !== '/') {
121+
return new SecureViewWrapper([
122+
'storage' => $storage,
123+
'mountPoint' => $mountPoint,
124+
]);
125+
}
126+
127+
return $storage;
128+
}
91129
}

lib/Controller/WopiController.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ public function checkFileInfo(string $fileId, string $access_token): JSONRespons
132132
$isSmartPickerEnabled = (bool)$wopi->getCanwrite() && !$isPublic && !$wopi->getDirect();
133133
$isTaskProcessingEnabled = $isSmartPickerEnabled && $this->taskProcessingManager->isTaskProcessingEnabled();
134134

135+
$share = $this->getShareForWopiToken($wopi, $file);
136+
$shouldUseSecureView = $this->permissionManager->shouldWatermark($file, $wopi->getEditorUid(), $share);
137+
135138
// If the file is locked manually by a user we want to open it read only for all others
136139
$canWriteThroughLock = true;
137140
try {
@@ -153,7 +156,7 @@ public function checkFileInfo(string $fileId, string $access_token): JSONRespons
153156
'UserExtraInfo' => [],
154157
'UserPrivateInfo' => [],
155158
'UserCanWrite' => $canWriteThroughLock && (bool)$wopi->getCanwrite(),
156-
'UserCanNotWriteRelative' => $isPublic || $this->encryptionManager->isEnabled() || $wopi->getHideDownload() || $wopi->isRemoteToken(),
159+
'UserCanNotWriteRelative' => $isPublic || $this->encryptionManager->isEnabled() || $wopi->getHideDownload() || $wopi->isRemoteToken() || $shouldUseSecureView,
157160
'PostMessageOrigin' => $wopi->getServerHost(),
158161
'LastModifiedTime' => Helper::toISO8601($file->getMTime()),
159162
'SupportsRename' => !$isVersion && !$wopi->isRemoteToken(),
@@ -163,11 +166,11 @@ public function checkFileInfo(string $fileId, string $access_token): JSONRespons
163166
'EnableShare' => $file->isShareable() && !$isVersion && !$isPublic,
164167
'HideUserList' => '',
165168
'EnableOwnerTermination' => $wopi->getCanwrite() && !$isPublic,
166-
'DisablePrint' => $wopi->getHideDownload(),
167-
'DisableExport' => $wopi->getHideDownload(),
168-
'DisableCopy' => $wopi->getHideDownload(),
169-
'HideExportOption' => $wopi->getHideDownload(),
170-
'HidePrintOption' => $wopi->getHideDownload(),
169+
'DisablePrint' => $wopi->getHideDownload() || $shouldUseSecureView,
170+
'DisableExport' => $wopi->getHideDownload() || $shouldUseSecureView,
171+
'DisableCopy' => $wopi->getHideDownload() || $shouldUseSecureView,
172+
'HideExportOption' => $wopi->getHideDownload() || $shouldUseSecureView,
173+
'HidePrintOption' => $wopi->getHideDownload() || $shouldUseSecureView,
171174
'DownloadAsPostMessage' => $wopi->getDirect(),
172175
'SupportsLocks' => $this->lockManager->isLockProviderAvailable(),
173176
'IsUserLocked' => $this->permissionManager->userIsFeatureLocked($wopi->getEditorUid()),
@@ -220,8 +223,7 @@ public function checkFileInfo(string $fileId, string $access_token): JSONRespons
220223
$response['TemplateSource'] = $this->getWopiUrlForTemplate($wopi);
221224
}
222225

223-
$share = $this->getShareForWopiToken($wopi, $file);
224-
if ($this->permissionManager->shouldWatermark($file, $wopi->getEditorUid(), $share)) {
226+
if ($shouldUseSecureView) {
225227
$email = $user !== null && !$isPublic ? $user->getEMailAddress() : '';
226228
$currentDateTime = new \DateTime(
227229
'now',
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace OCA\Richdocuments\Listener;
9+
10+
use OCA\Richdocuments\PermissionManager;
11+
use OCA\Talk\Events\OverwritePublicSharePropertiesEvent;
12+
use OCP\EventDispatcher\Event;
13+
use OCP\EventDispatcher\IEventListener;
14+
use OCP\Files\NotFoundException;
15+
16+
/** @template-implements IEventListener<OverwritePublicSharePropertiesEvent|Event> */
17+
class OverwritePublicSharePropertiesListener implements IEventListener {
18+
public function __construct(
19+
private PermissionManager $permissionManager,
20+
private ?string $userId,
21+
) {
22+
}
23+
24+
public function handle(Event $event): void {
25+
if (!$event instanceof OverwritePublicSharePropertiesEvent) {
26+
return;
27+
}
28+
29+
$share = $event->getShare();
30+
try {
31+
$node = $share->getNode();
32+
} catch (NotFoundException) {
33+
return;
34+
}
35+
36+
if ($this->permissionManager->shouldWatermark($node, $this->userId, $share)) {
37+
$share->setHideDownload(true);
38+
}
39+
}
40+
}

lib/Middleware/WOPIMiddleware.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public function __construct(
3333
private IRequest $request,
3434
private WopiMapper $wopiMapper,
3535
private LoggerInterface $logger,
36+
private bool $isWOPIRequest = false,
3637
) {
3738
}
3839

@@ -78,6 +79,8 @@ public function beforeController($controller, $methodName) {
7879
$this->logger->error('Failed to validate WOPI access', [ 'exception' => $e ]);
7980
throw new NotPermittedException();
8081
}
82+
83+
$this->isWOPIRequest = true;
8184
}
8285

8386
public function afterException($controller, $methodName, \Exception $exception): Response {
@@ -110,4 +113,8 @@ public function isWOPIAllowed(): bool {
110113
$this->logger->warning('WOPI request denied from ' . $userIp . ' as it does not match the configured ranges: ' . implode(', ', $allowedRanges));
111114
return false;
112115
}
116+
117+
public function isWOPIRequest(): bool {
118+
return $this->isWOPIRequest;
119+
}
113120
}

lib/PermissionManager.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ public function shouldWatermark(Node $node, ?string $userId = null, ?IShare $sha
117117
return false;
118118
}
119119

120+
if (!in_array($node->getMimetype(), $this->appConfig->getMimeTypes(), true)) {
121+
return false;
122+
}
123+
120124
$fileId = $node->getId();
121125

122126
$isUpdatable = $node->isUpdateable() && (!$share || $share->getPermissions() & Constants::PERMISSION_UPDATE);
@@ -163,7 +167,7 @@ public function shouldWatermark(Node $node, ?string $userId = null, ?IShare $sha
163167
}
164168

165169
if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_shareTalkPublic', 'no') === 'yes') {
166-
if ($userId === null && $share->getShareType() === IShare::TYPE_ROOM) {
170+
if ($userId === null && $share?->getShareType() === IShare::TYPE_ROOM) {
167171
return true;
168172
}
169173
}

lib/Storage/SecureViewWrapper.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Richdocuments\Storage;
10+
11+
use OC\Files\Storage\Wrapper\Wrapper;
12+
use OCA\Richdocuments\Middleware\WOPIMiddleware;
13+
use OCA\Richdocuments\PermissionManager;
14+
use OCP\Files\Folder;
15+
use OCP\Files\ForbiddenException;
16+
use OCP\Files\IRootFolder;
17+
use OCP\Files\Storage\ISharedStorage;
18+
use OCP\Files\Storage\IStorage;
19+
use OCP\IUserSession;
20+
use OCP\Server;
21+
22+
class SecureViewWrapper extends Wrapper {
23+
private PermissionManager $permissionManager;
24+
private WOPIMiddleware $wopiMiddleware;
25+
private IRootFolder $rootFolder;
26+
private IUserSession $userSession;
27+
28+
private string $mountPoint;
29+
30+
public function __construct(array $parameters) {
31+
parent::__construct($parameters);
32+
33+
$this->permissionManager = Server::get(PermissionManager::class);
34+
$this->wopiMiddleware = Server::get(WOPIMiddleware::class);
35+
$this->rootFolder = Server::get(IRootFolder::class);
36+
$this->userSession = Server::get(IUserSession::class);
37+
38+
$this->mountPoint = $parameters['mountPoint'];
39+
}
40+
41+
public function fopen($path, $mode) {
42+
$this->checkFileAccess($path);
43+
44+
return $this->storage->fopen($path, $mode);
45+
}
46+
47+
public function file_get_contents(string $path): false|string {
48+
$this->checkFileAccess($path);
49+
50+
return $this->storage->file_get_contents($path);
51+
}
52+
53+
public function copy(string $source, string $target): bool {
54+
$this->checkSourceAndTarget($source, $target);
55+
56+
return parent::copy($source, $target);
57+
}
58+
59+
public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool {
60+
$this->checkSourceAndTarget($sourceInternalPath, $targetInternalPath, $sourceStorage);
61+
62+
return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
63+
}
64+
65+
public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool {
66+
$this->checkSourceAndTarget($sourceInternalPath, $targetInternalPath, $sourceStorage);
67+
68+
return parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
69+
}
70+
71+
public function rename(string $source, string $target): bool {
72+
$this->checkSourceAndTarget($source, $target);
73+
74+
return parent::rename($source, $target);
75+
}
76+
77+
/**
78+
* @throws ForbiddenException
79+
*/
80+
private function checkFileAccess(string $path): void {
81+
if ($this->shouldSecure($path) && !$this->wopiMiddleware->isWOPIRequest()) {
82+
throw new ForbiddenException('Download blocked due the secure view policy', false);
83+
}
84+
}
85+
86+
private function shouldSecure(string $path, ?IStorage $sourceStorage = null): bool {
87+
if ($sourceStorage !== $this && $sourceStorage !== null) {
88+
$fp = $sourceStorage->fopen($path, 'r');
89+
fclose($fp);
90+
}
91+
92+
$storage = $sourceStorage ?? $this;
93+
94+
$isSharedStorage = $storage->instanceOfStorage(ISharedStorage::class);
95+
$mountNode = $this->rootFolder->get($storage->getMountPoint());
96+
$node = $mountNode instanceof Folder ? $mountNode->get($path) : $mountNode;
97+
$share = $isSharedStorage ? $node->getStorage()->getShare() : null;
98+
$userId = $this->userSession->getUser()?->getUID();
99+
100+
return $this->permissionManager->shouldWatermark($node, $userId, $share);
101+
}
102+
103+
104+
private function checkSourceAndTarget(string $source, string $target, ?IStorage $sourceStorage = null): void {
105+
if ($this->shouldSecure($source, $sourceStorage) && !$this->shouldSecure($target)) {
106+
throw new ForbiddenException('Download blocked due the secure view policy. The source requires secure view that the target cannot offer.', false);
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)