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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/files_sharing/lib/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use OC\Files\Cache\FileAccess;
use OC\Files\Filesystem;
use OC\Files\Mount\MountPoint;
use OC\Share20\Manager;
use OCP\Constants;
use OCP\Files\Folder;
use OCP\Files\Mount\IMountManager;
Expand Down Expand Up @@ -58,6 +59,10 @@ private static function moveShareInOrOutOfShare($path): void {

$shareManager = Server::get(\OCP\Share\IManager::class);

if ($shareManager instanceof Manager) {
$shareManager->restrictDownloadOfReshares($src);
}

// We intentionally include invalid shares, as they have been automatically invalidated due to the node no longer
// being accessible for the user. Only in this case where we adjust the share after it was moved we want to ignore
// this to be able to still adjust it.
Expand Down
246 changes: 246 additions & 0 deletions apps/files_sharing/tests/ReshareAttributesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
<?php

declare(strict_types=1);

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

namespace OCA\Files_Sharing\Tests;

use OC\Files\View;
use OCA\Files_Sharing\Helper;
use OCP\Constants;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\NotFoundException;
use OCP\IDBConnection;
use OCP\Server;
use OCP\Share\IShare;
use PHPUnit\Framework\Attributes\Group;

#[Group(name: 'DB')]
class ReshareAttributesTest extends TestCase {
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
Helper::registerHooks();
}

protected function setUp(): void {
parent::setUp();

$this->view->mkdir('folder');
$this->view->mkdir('folder/sub');
$this->view->file_put_contents('folder/sub/file.txt', $this->data);
}

private function createShare(int $type, string $path, string $initiator, ?string $recipient = null): IShare {
$this->loginHelper($initiator);
if ($recipient !== null) {
return $this->share($type, $path, $initiator, $recipient, Constants::PERMISSION_ALL);
}

$share = $this->shareManager->newShare()
->setShareType($type)
->setSharedBy($initiator)
->setNode($this->rootFolder->getUserFolder($initiator)->get($path))
->setPermissions(Constants::PERMISSION_READ);
return $this->shareManager->createShare($share);
}

private function setDownload(IShare $share, bool $canDownload): void {
$this->loginHelper($share->getSharedBy());
$share = $this->shareManager->getShareById($share->getFullId());
$attributes = $share->getAttributes() ?? $share->newAttributes();
$attributes->setAttribute('permissions', 'download', $canDownload);
$share->setAttributes($attributes);
$this->shareManager->updateShare($share);
}

private function canDownload(IShare $share): bool {
return $this->shareManager->getShareById($share->getFullId())->canDownload();
}

/**
* Moving between storages on object store primary storage creates new
* file ids, so the shares of the moved node no longer resolve.
*/
private function assertCannotServeDownloads(IShare $share): void {
$share = $this->shareManager->getShareById($share->getFullId());
try {
$share->getNode();
$nodeExists = true;
} catch (NotFoundException) {
$nodeExists = false;
}
$this->assertFalse($nodeExists && $share->canDownload());
}

public function testRestrictingDownloadRestrictsReshareChain(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$reshare = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_USER3);
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);
$fileLink = $this->createShare(IShare::TYPE_LINK, 'folder/sub/file.txt', self::TEST_FILES_SHARING_API_USER2);
$chainedLink = $this->createShare(IShare::TYPE_LINK, 'folder/sub', self::TEST_FILES_SHARING_API_USER3);

$this->setDownload($share, false);

$this->assertFalse($this->canDownload($reshare));
$this->assertFalse($this->canDownload($link));
$this->assertFalse($this->canDownload($fileLink));
$this->assertFalse($this->canDownload($chainedLink));
}

public function testRestrictingDownloadKeepsSharesOfTheOwner(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$ownerShare = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER3);
$ownerLink = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER1);

$this->setDownload($share, false);

$this->assertTrue($this->canDownload($ownerShare));
$this->assertTrue($this->canDownload($ownerLink));
}

public function testRestrictingDownloadKeepsResharesWithAnotherAllowingShare(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$groupShare = $this->createShare(IShare::TYPE_GROUP, 'folder', self::TEST_FILES_SHARING_API_USER1, 'group1');
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);

$this->setDownload($share, false);
$this->assertTrue($this->canDownload($link));

$this->setDownload($groupShare, false);
$this->assertFalse($this->canDownload($link));
}

public function testRestrictingDownloadRestrictsCyclicReshares(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_USER3);
$cyclicShare = $this->createShare(IShare::TYPE_GROUP, 'folder', self::TEST_FILES_SHARING_API_USER3, 'group1');
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);

$this->setDownload($share, false);

$this->assertFalse($this->canDownload($cyclicShare));
$this->assertFalse($this->canDownload($link));
}

public function testRestrictingDownloadKeepsCyclicResharesReachableFromTheOwner(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$otherShare = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER3);
$this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_USER4);
$this->createShare(IShare::TYPE_GROUP, 'folder', self::TEST_FILES_SHARING_API_USER3, 'group1');
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);

$this->setDownload($share, false);
$this->assertTrue($this->canDownload($link));

$this->setDownload($otherShare, false);
$this->assertFalse($this->canDownload($link));
}

public function testRestrictingDownloadOfReshareRestrictsItsReshares(): void {
$this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$reshare = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_USER3);
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);
$chainedLink = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER3);

$this->setDownload($reshare, false);

$this->assertTrue($this->canDownload($link));
$this->assertFalse($this->canDownload($chainedLink));
}

public function testAllowingDownloadAgainKeepsResharesRestricted(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);

$this->setDownload($share, false);
$this->setDownload($share, true);

$this->assertFalse($this->canDownload($link));
}

public function testUpdatingPermissionsKeepsResharesUnchanged(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);

$this->loginHelper(self::TEST_FILES_SHARING_API_USER1);
$share = $this->shareManager->getShareById($share->getFullId());
$share->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_SHARE);
$this->shareManager->updateShare($share);

$this->assertNull($this->shareManager->getShareById($link->getFullId())->getAttributes());
}

public function testRestrictingDownloadRestrictsResharesOfUnresolvableSharers(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$link = $this->createShare(IShare::TYPE_LINK, 'folder', self::TEST_FILES_SHARING_API_USER2);

$qb = Server::get(IDBConnection::class)->getQueryBuilder();
$qb->update('share')
->set('uid_initiator', $qb->createNamedParameter('non-existing-user'))
->where($qb->expr()->eq('id', $qb->createNamedParameter($link->getId())))
->executeStatement();

$this->setDownload($share, false);

$this->assertFalse($this->canDownload($link));
}

public function testMovingIntoRestrictedShareRestrictsReshares(): void {
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$this->setDownload($share, false);

$this->loginHelper(self::TEST_FILES_SHARING_API_USER2);
$view = new View('/' . self::TEST_FILES_SHARING_API_USER2 . '/files');
$view->mkdir('own');
$view->file_put_contents('own/file.txt', $this->data);
$link = $this->createShare(IShare::TYPE_LINK, 'own', self::TEST_FILES_SHARING_API_USER2);
$reshare = $this->createShare(IShare::TYPE_USER, 'own', self::TEST_FILES_SHARING_API_USER2, self::TEST_FILES_SHARING_API_USER3);
$chainedLink = $this->createShare(IShare::TYPE_LINK, 'own', self::TEST_FILES_SHARING_API_USER3);

$this->loginHelper(self::TEST_FILES_SHARING_API_USER2);
// Start from fresh mounts, as a separate request would
Server::get(IUserMountCache::class)->clear();
$view->rename('own', 'folder/own');

$this->assertCannotServeDownloads($link);
$this->assertCannotServeDownloads($reshare);
$this->assertCannotServeDownloads($chainedLink);
}

public function testMovingByOwnerIntoRestrictedShareRestrictsReshares(): void {
$this->view->mkdir('open');
$this->view->mkdir('open/moved');
$share = $this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$this->createShare(IShare::TYPE_USER, 'open', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);
$this->setDownload($share, false);
$link = $this->createShare(IShare::TYPE_LINK, 'open/moved', self::TEST_FILES_SHARING_API_USER2);
$this->assertTrue($this->canDownload($link));

$this->loginHelper(self::TEST_FILES_SHARING_API_USER1);
// Start from fresh mounts, as a separate request would
Server::get(IUserMountCache::class)->clear();
$this->view->rename('open/moved', 'folder/moved');

$this->assertFalse($this->canDownload($link));
}

public function testMovingIntoAllowingShareKeepsReshares(): void {
$this->createShare(IShare::TYPE_USER, 'folder', self::TEST_FILES_SHARING_API_USER1, self::TEST_FILES_SHARING_API_USER2);

$this->loginHelper(self::TEST_FILES_SHARING_API_USER2);
$view = new View('/' . self::TEST_FILES_SHARING_API_USER2 . '/files');
$view->mkdir('own');
$link = $this->createShare(IShare::TYPE_LINK, 'own', self::TEST_FILES_SHARING_API_USER2);

$this->loginHelper(self::TEST_FILES_SHARING_API_USER2);
// Start from fresh mounts, as a separate request would
Server::get(IUserMountCache::class)->clear();
$view->rename('own', 'folder/own');

$this->assertTrue($this->canDownload($link));
}
}
30 changes: 14 additions & 16 deletions lib/private/Files/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use OCP\Files\InvalidCharacterInPathException;
use OCP\Files\InvalidDirectoryException;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\Mount\IMountManager;
use OCP\Files\Mount\IMountPoint;
use OCP\Files\NotFoundException;
Expand Down Expand Up @@ -1881,32 +1882,29 @@ private function targetIsNotShared(string $user, string $targetPath): bool {
$providers = [
IShare::TYPE_USER,
IShare::TYPE_GROUP,
IShare::TYPE_LINK,
IShare::TYPE_EMAIL,
IShare::TYPE_CIRCLE,
IShare::TYPE_ROOM,
IShare::TYPE_DECK,
];
$shareManager = Server::get(IManager::class);
/** @var IShare[] $shares */
$shares = array_merge(...array_map(function (int $type) use ($shareManager, $user) {
return $shareManager->getSharesBy($user, $type);
}, $providers));
$rootFolder = Server::get(IRootFolder::class);
$userFolderPath = '/' . $user . '/files';

foreach ($shares as $share) {
for ($path = $targetPath; str_starts_with($path, $userFolderPath . '/'); $path = dirname($path)) {
try {
$sharedPath = $share->getNode()->getPath();
} catch (NotFoundException $e) {
// node is not found, ignoring
$this->logger->debug(
'Could not find the node linked to a share',
['app' => 'files', 'exception' => $e]);
$node = $rootFolder->get($path);
} catch (NotFoundException) {
continue;
}
if ($targetPath === $sharedPath || str_starts_with($targetPath, $sharedPath . '/')) {
$this->logger->debug(
'It is not allowed to move one mount point into a shared folder',
['app' => 'files']);
return false;
foreach ($providers as $type) {
if ($shareManager->getSharesBy($user, $type, $node, false, 1) !== []) {
$this->logger->debug(
'It is not allowed to move one mount point into a shared folder',
['app' => 'files']);
return false;
}
}
}

Expand Down
Loading
Loading