Compare commits

...

4 Commits

Author SHA1 Message Date
Ferdinand Thiessen
bf63db1e8b refactor(files_sharing): Handle access to shares using the direct download endpoint
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2024-10-07 17:31:39 +02:00
Ferdinand Thiessen
4bcbe8a894 feat: Allow to pass Node to BeforeDirectFileDownloadEvent
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2024-10-07 17:29:57 +02:00
Ferdinand Thiessen
064ea9c134 refactor(files_sharing): Move share access handling from controller to event listener
This ensures we catch all share access attempts and not only the controller ones (e.g. when using the zip folder plugin).

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2024-10-07 17:18:11 +02:00
Ferdinand Thiessen
f33b280d1a feat(files_sharing): Add step constants to ShareLinkAccessedEvent
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2024-10-07 17:16:34 +02:00
6 changed files with 260 additions and 242 deletions

View File

@@ -9,20 +9,18 @@ namespace OCA\Files_Sharing\Controller;
use OC\Security\CSP\ContentSecurityPolicy;
use OCA\DAV\Connector\Sabre\PublicAuth;
use OCA\FederatedFileSharing\FederatedShareProvider;
use OCA\Files_Sharing\Activity\Providers\Downloads;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Files_Sharing\Event\ShareLinkAccessedEvent;
use OCA\Files_Sharing\Services\ShareAccessService;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\AuthPublicShareController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Defaults;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IConfig;
@@ -55,12 +53,12 @@ class ShareController extends AuthPublicShareController {
public function __construct(
string $appName,
IRequest $request,
protected IConfig $config,
ISession $session,
IURLGenerator $urlGenerator,
protected IConfig $config,
protected IUserManager $userManager,
protected \OCP\Activity\IManager $activityManager,
protected ShareManager $shareManager,
ISession $session,
protected IPreview $previewManager,
protected IRootFolder $rootFolder,
protected FederatedShareProvider $federatedShareProvider,
@@ -70,6 +68,7 @@ class ShareController extends AuthPublicShareController {
protected ISecureRandom $secureRandom,
protected Defaults $defaults,
private IPublicShareTemplateFactory $publicShareTemplateFactory,
private ShareAccessService $accessService,
) {
parent::__construct($appName, $request, $session, $urlGenerator);
}
@@ -195,64 +194,9 @@ class ShareController extends AuthPublicShareController {
$this->session->set(PublicAuth::DAV_AUTHENTICATED, $this->share->getId());
}
/** @inheritDoc */
protected function authFailed() {
$this->emitAccessShareHook($this->share, 403, 'Wrong password');
$this->emitShareAccessEvent($this->share, self::SHARE_AUTH, 403, 'Wrong password');
}
/**
* throws hooks when a share is attempted to be accessed
*
* @param \OCP\Share\IShare|string $share the Share instance if available,
* otherwise token
* @param int $errorCode
* @param string $errorMessage
*
* @throws \OCP\HintException
* @throws \OC\ServerNotAvailableException
*
* @deprecated use OCP\Files_Sharing\Event\ShareLinkAccessedEvent
*/
protected function emitAccessShareHook($share, int $errorCode = 200, string $errorMessage = '') {
$itemType = $itemSource = $uidOwner = '';
$token = $share;
$exception = null;
if ($share instanceof \OCP\Share\IShare) {
try {
$token = $share->getToken();
$uidOwner = $share->getSharedBy();
$itemType = $share->getNodeType();
$itemSource = $share->getNodeId();
} catch (\Exception $e) {
// we log what we know and pass on the exception afterwards
$exception = $e;
}
}
\OC_Hook::emit(Share::class, 'share_link_access', [
'itemType' => $itemType,
'itemSource' => $itemSource,
'uidOwner' => $uidOwner,
'token' => $token,
'errorCode' => $errorCode,
'errorMessage' => $errorMessage
]);
if (!is_null($exception)) {
throw $exception;
}
}
/**
* Emit a ShareLinkAccessedEvent event when a share is accessed, downloaded, auth...
*/
protected function emitShareAccessEvent(IShare $share, string $step = '', int $errorCode = 200, string $errorMessage = ''): void {
if ($step !== self::SHARE_ACCESS &&
$step !== self::SHARE_AUTH &&
$step !== self::SHARE_DOWNLOAD) {
return;
}
$this->eventDispatcher->dispatchTyped(new ShareLinkAccessedEvent($share, $step, $errorCode, $errorMessage));
$this->accessService->accessWrongPassword($this->share);
}
/**
@@ -261,7 +205,7 @@ class ShareController extends AuthPublicShareController {
* @param Share\IShare $share
* @return bool
*/
private function validateShare(\OCP\Share\IShare $share) {
private function validateSharePermissions(\OCP\Share\IShare $share) {
// If the owner is disabled no access to the link is granted
$owner = $this->userManager->get($share->getShareOwner());
if ($owner === null || !$owner->isEnabled()) {
@@ -292,12 +236,11 @@ class ShareController extends AuthPublicShareController {
try {
$share = $this->shareManager->getShareByToken($this->getToken());
} catch (ShareNotFound $e) {
// The share does not exists, we do not emit an ShareLinkAccessedEvent
$this->emitAccessShareHook($this->getToken(), 404, 'Share not found');
throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available'));
}
if (!$this->validateShare($share)) {
if (!$this->validateSharePermissions($share)) {
$this->accessService->shareNotFound($share);
throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available'));
}
@@ -307,28 +250,17 @@ class ShareController extends AuthPublicShareController {
$templateProvider = $this->publicShareTemplateFactory->getProvider($share);
$response = $templateProvider->renderPage($share, $this->getToken(), $path);
} catch (NotFoundException $e) {
$this->emitAccessShareHook($share, 404, 'Share not found');
$this->emitShareAccessEvent($share, ShareController::SHARE_ACCESS, 404, 'Share not found');
$this->accessService->shareNotFound($share);
throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available'));
}
// We can't get the path of a file share
try {
if ($shareNode instanceof \OCP\Files\File && $path !== '') {
$this->emitAccessShareHook($share, 404, 'Share not found');
$this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found');
throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available'));
}
} catch (\Exception $e) {
$this->emitAccessShareHook($share, 404, 'Share not found');
$this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found');
throw $e;
if (($shareNode instanceof \OCP\Files\File) && $path !== '') {
$this->accessService->shareNotFound($share);
throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available'));
}
$this->emitAccessShareHook($share);
$this->emitShareAccessEvent($share, self::SHARE_ACCESS);
$this->accessService->accessShare($share);
return $response;
}
@@ -349,58 +281,13 @@ class ShareController extends AuthPublicShareController {
$share = $this->shareManager->getShareByToken($token);
if (!($share->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return new \OCP\AppFramework\Http\DataResponse('Share has no read permission');
}
if (!$this->validateShare($share)) {
if (!$this->validateSharePermissions($share)) {
throw new NotFoundException();
}
// Single file share
if ($share->getNode() instanceof \OCP\Files\File) {
// Single file download
$this->singleFileDownloaded($share, $share->getNode());
if (!($share->getPermissions() & \OCP\Constants::PERMISSION_READ)) {
return new \OCP\AppFramework\Http\DataResponse('Share has no read permission', Http::STATUS_FORBIDDEN);
}
// Directory share
else {
/** @var \OCP\Files\Folder $node */
$node = $share->getNode();
// Try to get the path
if ($path !== '') {
try {
$node = $node->get($path);
} catch (NotFoundException $e) {
$this->emitAccessShareHook($share, 404, 'Share not found');
$this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD, 404, 'Share not found');
return new NotFoundResponse();
}
}
if ($node instanceof \OCP\Files\Folder) {
if ($files === null || $files === '') {
// The folder is downloaded
$this->singleFileDownloaded($share, $share->getNode());
} else {
$fileList = json_decode($files);
// in case we get only a single file
if (!is_array($fileList)) {
$fileList = [$fileList];
}
foreach ($fileList as $file) {
$subNode = $node->get($file);
$this->singleFileDownloaded($share, $subNode);
}
}
} else {
// Single file download
$this->singleFileDownloaded($share, $share->getNode());
}
}
$this->emitAccessShareHook($share);
$this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD);
$davUrl = '/public.php/dav/files/' . $token . '/?accept=zip';
if ($files !== null) {
@@ -409,76 +296,4 @@ class ShareController extends AuthPublicShareController {
return new RedirectResponse($this->urlGenerator->getAbsoluteURL($davUrl));
}
/**
* create activity if a single file was downloaded from a link share
*
* @param Share\IShare $share
* @throws NotFoundException when trying to download a folder of a "hide download" share
*/
protected function singleFileDownloaded(Share\IShare $share, \OCP\Files\Node $node) {
if ($share->getHideDownload() && $node instanceof Folder) {
throw new NotFoundException('Downloading a folder');
}
$fileId = $node->getId();
$userFolder = $this->rootFolder->getUserFolder($share->getSharedBy());
$userNode = $userFolder->getFirstNodeById($fileId);
$ownerFolder = $this->rootFolder->getUserFolder($share->getShareOwner());
$userPath = $userFolder->getRelativePath($userNode->getPath());
$ownerPath = $ownerFolder->getRelativePath($node->getPath());
$remoteAddress = $this->request->getRemoteAddress();
$dateTime = new \DateTime();
$dateTime = $dateTime->format('Y-m-d H');
$remoteAddressHash = md5($dateTime . '-' . $remoteAddress);
$parameters = [$userPath];
if ($share->getShareType() === IShare::TYPE_EMAIL) {
if ($node instanceof \OCP\Files\File) {
$subject = Downloads::SUBJECT_SHARED_FILE_BY_EMAIL_DOWNLOADED;
} else {
$subject = Downloads::SUBJECT_SHARED_FOLDER_BY_EMAIL_DOWNLOADED;
}
$parameters[] = $share->getSharedWith();
} else {
if ($node instanceof \OCP\Files\File) {
$subject = Downloads::SUBJECT_PUBLIC_SHARED_FILE_DOWNLOADED;
$parameters[] = $remoteAddressHash;
} else {
$subject = Downloads::SUBJECT_PUBLIC_SHARED_FOLDER_DOWNLOADED;
$parameters[] = $remoteAddressHash;
}
}
$this->publishActivity($subject, $parameters, $share->getSharedBy(), $fileId, $userPath);
if ($share->getShareOwner() !== $share->getSharedBy()) {
$parameters[0] = $ownerPath;
$this->publishActivity($subject, $parameters, $share->getShareOwner(), $fileId, $ownerPath);
}
}
/**
* publish activity
*
* @param string $subject
* @param array $parameters
* @param string $affectedUser
* @param int $fileId
* @param string $filePath
*/
protected function publishActivity($subject,
array $parameters,
$affectedUser,
$fileId,
$filePath) {
$event = $this->activityManager->generateEvent();
$event->setApp('files_sharing')
->setType('public_links')
->setSubject($subject, $parameters)
->setAffectedUser($affectedUser)
->setObject('files', $fileId, $filePath);
$this->activityManager->publish($event);
}
}

View File

@@ -13,24 +13,24 @@ use OCP\EventDispatcher\Event;
use OCP\Share\IShare;
class ShareLinkAccessedEvent extends Event {
/** @var IShare */
private $share;
/** @since 31.0.0 */
public const STEP_ACCESS = 'access';
/** @since 31.0.0 */
public const STEP_AUTH = 'auth';
/** @since 31.0.0 */
public const STEP_DOWNLOAD = 'download';
/** @var string */
private $step;
/** @var int */
private $errorCode;
/** @var string */
private $errorMessage;
public function __construct(IShare $share, string $step = '', int $errorCode = 200, string $errorMessage = '') {
/**
* @param ShareLinkAccessedEvent::STEP_* $step
*/
public function __construct(
private IShare $share,
private string $step = '',
private int $errorCode = 200,
private string $errorMessage = '',
) {
parent::__construct();
$this->share = $share;
$this->step = $step;
$this->errorCode = $errorCode;
$this->errorMessage = $errorMessage;
}
public function getShare(): IShare {

View File

@@ -9,11 +9,13 @@ declare(strict_types=1);
namespace OCA\Files_Sharing\Listener;
use OCA\Files_Sharing\Services\ShareAccessService;
use OCA\Files_Sharing\ViewOnly;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\BeforeDirectFileDownloadEvent;
use OCP\Files\IRootFolder;
use OCP\Files\Storage\ISharedStorage;
use OCP\IUserSession;
/**
@@ -24,6 +26,7 @@ class BeforeDirectFileDownloadListener implements IEventListener {
public function __construct(
private IUserSession $userSession,
private IRootFolder $rootFolder,
private ShareAccessService $accessService,
) {
}
@@ -42,6 +45,19 @@ class BeforeDirectFileDownloadListener implements IEventListener {
if (!$viewOnlyHandler->check($pathsToCheck)) {
$event->setSuccessful(false);
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
return;
}
}
$node = $event->getNode();
if ($node !== null) {
$storage = $node->getStorage();
if ($storage->instanceOfStorage(ISharedStorage::class)) {
/** @var ISharedStorage $storage */
$share = $storage->getShare();
$this->accessService->shareDownloaded($share);
// All we now need to do is log the download
$this->accessService->sharedFileDownloaded($share, $node);
}
}
}

View File

@@ -9,12 +9,18 @@ declare(strict_types=1);
namespace OCA\Files_Sharing\Listener;
use OCA\Files_Sharing\ViewOnly;
use OCA\Files_Sharing\Services\ShareAccessService;
use OCP\Activity\IManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Storage\ISharedStorage;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\L10N\IFactory;
/**
* @template-implements IEventListener<BeforeZipCreatedEvent|Event>
@@ -24,6 +30,10 @@ class BeforeZipCreatedListener implements IEventListener {
public function __construct(
private IUserSession $userSession,
private IRootFolder $rootFolder,
private IManager $activityManager,
private IRequest $request,
private IFactory $l10n,
private ShareAccessService $accessService,
) {
}
@@ -32,28 +42,52 @@ class BeforeZipCreatedListener implements IEventListener {
return;
}
$dir = $event->getDirectory();
$files = $event->getFiles();
// The view-only handling is already managed by the DAV plugin
// so all we need to do is to ensure that the share is not a "hide-download" share where we also forbid downloading
$pathsToCheck = [];
foreach ($files as $file) {
$pathsToCheck[] = $dir . '/' . $file;
$folder = $event->getFolder();
if ($folder === null) {
$user = $this->userSession->getUser();
if ($user === null) {
return;
} else {
$folder = $this->rootFolder->getUserFolder($user->getUID())->get($event->getDirectory());
assert($folder instanceof Folder, 'Directory is not a folder but a file');
}
}
// Check only for user/group shares. Don't restrict e.g. share links
$user = $this->userSession->getUser();
if ($user) {
$viewOnlyHandler = new ViewOnly(
$this->rootFolder->getUserFolder($user->getUID())
);
if (!$viewOnlyHandler->check($pathsToCheck)) {
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
$event->setSuccessful(false);
} else {
$event->setSuccessful(true);
}
$files = $event->getFiles();
if (empty($files)) {
$files = [$folder];
} else {
$event->setSuccessful(true);
$files = array_map(fn (string $path) => $folder->get($path), $files);
}
$notified = false;
foreach ($files as $file) {
$storage = $file->getStorage();
if ($storage->instanceOfStorage(ISharedStorage::class)) {
/** @var ISharedStorage $storage */
$share = $storage->getShare();
// Check if it is allowed to download this file - if "hide-download" is enabled but a zip file is created
// it means the users managed to access the endpoint manually -> block it
if ($share->getHideDownload()) {
$event->setSuccessful(false);
$event->setErrorMessage($this->l10n->get('files_sharing')->t('Download permission of share not granted.'));
// we can early return now as the event is set to failed state
return;
}
// only notify once for the zip download
if ($notified == false) {
$this->accessService->shareDownloaded($share);
$notified = true;
}
// All we now need to do is log the download
$this->accessService->sharedFileDownloaded($share, $file);
}
}
}
}

View File

@@ -0,0 +1,136 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing\Services;
use OCA\Files_Sharing\Activity\Providers\Downloads;
use OCA\Files_Sharing\Event\ShareLinkAccessedEvent;
use OCP\Activity\IManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IRequest;
use OCP\Share\IShare;
/**
* Service to handle activity and event emission for access to shares.
*/
class ShareAccessService {
public const SHARE_ACCESS = 'access';
public const SHARE_AUTH = 'auth';
public const SHARE_DOWNLOAD = 'download';
public function __construct(
protected IEventDispatcher $eventDispatcher,
protected IManager $activityManager,
protected IRootFolder $rootFolder,
protected ITimeFactory $timeFactory,
protected IRequest $request,
) {
}
public function shareNotFound(IShare $share): void {
$event = new ShareLinkAccessedEvent($share, ShareLinkAccessedEvent::STEP_ACCESS, 404, 'Share not found');
$this->eventDispatcher->dispatchTyped($event);
}
public function accessShare(IShare $share): void {
$event = new ShareLinkAccessedEvent($share, ShareLinkAccessedEvent::STEP_ACCESS);
$this->eventDispatcher->dispatchTyped($event);
}
public function accessWrongPassword(IShare $share): void {
$event = new ShareLinkAccessedEvent($share, ShareLinkAccessedEvent::STEP_AUTH, 403, 'Wrong password');
$this->eventDispatcher->dispatchTyped($event);
}
public function shareDownloaded(IShare $share): void {
$event = new ShareLinkAccessedEvent($share, ShareLinkAccessedEvent::STEP_DOWNLOAD);
$this->eventDispatcher->dispatchTyped($event);
}
public function sharedFileDownloaded(IShare $share, Node $node): void {
$incognito = \OC_User::isIncognitoMode();
\OC_User::setIncognitoMode(true);
$fileId = $node->getId();
$userFolder = $this->rootFolder->getUserFolder($share->getSharedBy());
$userNode = $userFolder->getFirstNodeById($fileId);
$ownerFolder = $this->rootFolder->getUserFolder($share->getShareOwner());
$userPath = $userFolder->getRelativePath($userNode->getPath());
$ownerPath = $ownerFolder->getRelativePath($node->getPath());
@[$subject, $parameters] = $this->getFileDownloadedSubject($share, $node);
if ($subject !== null) {
$parameters = array_merge([$userPath], $parameters);
$this->publishActivity($subject, $parameters, $share->getSharedBy(), $fileId, $userPath);
if ($share->getShareOwner() !== $share->getSharedBy()) {
$parameters[0] = $ownerPath;
$this->publishActivity($subject, $parameters, $share->getShareOwner(), $fileId, $ownerPath);
}
}
\OC_User::setIncognitoMode($incognito);
}
/**
* Get the subject and parameters for a shared file download.
*
* @return null|array{0: string, 1: array}
*/
private function getFileDownloadedSubject(IShare $share, Node $node): ?array {
$isFile = $node instanceof \OCP\Files\File;
$parameters = [];
switch ($share->getShareType()) {
case IShare::TYPE_EMAIL:
$subject = match ($isFile) {
true => Downloads::SUBJECT_SHARED_FILE_BY_EMAIL_DOWNLOADED,
false => Downloads::SUBJECT_SHARED_FOLDER_BY_EMAIL_DOWNLOADED,
};
$parameters = [$share->getSharedWith()];
// no break
case IShare::TYPE_LINK:
$subject = match ($isFile) {
true => Downloads::SUBJECT_PUBLIC_SHARED_FILE_DOWNLOADED,
false => Downloads::SUBJECT_PUBLIC_SHARED_FOLDER_DOWNLOADED,
};
$dateTime = $this->timeFactory
->getDateTime()
->format('Y-m-d H');
$remoteAddress = $this->request->getRemoteAddress();
$parameters = [md5($dateTime . '-' . $remoteAddress)];
// no break
default:
// All other types not yet receive download notifications
}
if (isset($subject)) {
return [$subject, $parameters];
}
return null;
}
/**
* Publish the activity through the activity manager.
*/
private function publishActivity(
string $subject,
array $parameters,
string $affectedUser,
int $fileId,
string $filePath,
) {
$event = $this->activityManager->generateEvent();
$event->setApp('files_sharing')
->setType('public_links')
->setSubject($subject, $parameters)
->setAffectedUser($affectedUser)
->setObject('files', $fileId, $filePath);
$this->activityManager->publish($event);
}
}

View File

@@ -9,24 +9,34 @@ declare(strict_types=1);
namespace OCP\Files\Events;
use OCP\EventDispatcher\Event;
use OCP\Files\Node;
/**
* This event is triggered when a user tries to download a file
* directly.
* This event is triggered when a user tries to download a file directly.
* Possible reasons are i.a. using the direct-download endpoint or WebDAV `GET` request.
*
* By setting `successful` to false the download can be aborted and denied.
*
* @since 25.0.0
*/
class BeforeDirectFileDownloadEvent extends Event {
private string $path;
private ?Node $node = null;
private bool $successful = true;
private ?string $errorMessage = null;
/**
* @since 25.0.0
* @since 31.0.0 support `Node` as parameter for $path - passing a string is deprecated now.
*/
public function __construct(string $path) {
public function __construct(string|Node $path) {
parent::__construct();
$this->path = $path;
if ($path instanceof Node) {
$this->node = $path;
$this->path = $path->getPath();
} else {
$this->path = $path;
}
}
/**
@@ -36,6 +46,13 @@ class BeforeDirectFileDownloadEvent extends Event {
return $this->path;
}
/**
* @since 31.0.0
*/
public function getNode(): ?Node {
return $this->node;
}
/**
* @since 25.0.0
*/