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
3 changes: 3 additions & 0 deletions apps/dav/appinfo/v1/caldav.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
// Backends
use OC\KnownUser\KnownUserService;
use OC\Lockdown\LockdownManager;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarRoot;
use OCA\DAV\CalDAV\DefaultCalendarValidator;
Expand All @@ -22,6 +23,7 @@
use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin;
use OCA\DAV\Connector\Sabre\MaintenancePlugin;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\Connector\Sabre\TokenScopePlugin;
use OCP\Accounts\IAccountManager;
use OCP\App\IAppManager;
use OCP\EventDispatcher\IEventDispatcher;
Expand Down Expand Up @@ -110,6 +112,7 @@
// Add plugins
$server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), $davL10n));
$server->addPlugin(new \Sabre\DAV\Auth\Plugin($authBackend));
$server->addPlugin(new TokenScopePlugin(Server::get(LockdownManager::class)));
$server->addPlugin(new \Sabre\CalDAV\Plugin());

$server->addPlugin(new LegacyDAVACL());
Expand Down
3 changes: 3 additions & 0 deletions apps/dav/appinfo/v1/carddav.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
// Backends
use OC\KnownUser\KnownUserService;
use OC\Lockdown\LockdownManager;
use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\CalDAV\Principal\Collection as PrincipalCollection;
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
Expand All @@ -21,6 +22,7 @@
use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin;
use OCA\DAV\Connector\Sabre\MaintenancePlugin;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\Connector\Sabre\TokenScopePlugin;
use OCP\Accounts\IAccountManager;
use OCP\App\IAppManager;
use OCP\EventDispatcher\IEventDispatcher;
Expand Down Expand Up @@ -92,6 +94,7 @@
// Add plugins
$server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), Server::get(IL10nFactory::class)->get('dav')));
$server->addPlugin(new \Sabre\DAV\Auth\Plugin($authBackend));
$server->addPlugin(new TokenScopePlugin(Server::get(LockdownManager::class)));
$server->addPlugin(new Plugin());

$server->addPlugin(new LegacyDAVACL());
Expand Down
4 changes: 4 additions & 0 deletions apps/dav/appinfo/v1/webdav.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
use OC\Files\Filesystem;
use OC\Lockdown\LockdownManager;
use OCA\DAV\Connector\Sabre\Auth;
use OCA\DAV\Connector\Sabre\BearerAuth;
use OCA\DAV\Connector\Sabre\ServerFactory;
use OCA\DAV\Connector\Sabre\TokenScopePlugin;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Mount\IMountManager;
Expand Down Expand Up @@ -76,6 +78,8 @@
// use the view for the logged in user
return Filesystem::getView();
});
// The legacy tree is the user's files root
$server->addPlugin(new TokenScopePlugin(Server::get(LockdownManager::class), prefix: 'files/'));

// allow setup of additional plugins
$event = new SabrePluginEvent($server);
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@
'OCA\\DAV\\Connector\\Sabre\\StreamedPropFindNotFoundPlugin' => $baseDir . '/../lib/Connector/Sabre/StreamedPropFindNotFoundPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TokenScopePlugin' => $baseDir . '/../lib/Connector/Sabre/TokenScopePlugin.php',
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => $baseDir . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Connector\\Sabre\\StreamedPropFindNotFoundPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/StreamedPropFindNotFoundPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TokenScopePlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TokenScopePlugin.php',
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
Expand Down
9 changes: 9 additions & 0 deletions apps/dav/lib/AppInfo/PluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ class PluginManager {
*/
private $calendarPlugins = [];

/** @var array<string, string> collection name => app id */
private array $collectionApps = [];

/** @var bool */
private $populated = false;

Expand Down Expand Up @@ -91,6 +94,11 @@ public function getAppCollections() {
return $this->collections;
}

public function getCollectionApp(string $name): ?string {
$this->populate();
return $this->collectionApps[$name] ?? null;
}

/**
* @return IAddressBookProvider[]
*/
Expand Down Expand Up @@ -134,6 +142,7 @@ private function populate(): void {
$collections = $this->loadSabreCollectionsFromInfoXml($this->extractCollectionList($info));
foreach ($collections as $collection) {
$this->collections[] = $collection;
$this->collectionApps[$collection->getName()] = $app;
}

$addresbookPlugins = $this->loadSabreAddressBookPluginsFromInfoXml($this->extractAddressBookPluginList($info));
Expand Down
114 changes: 114 additions & 0 deletions apps/dav/lib/Connector/Sabre/TokenScopePlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\Connector\Sabre;

use OC\Lockdown\LockdownManager;
use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\CalDAV\Plugin as CalDAVPlugin;
use OCA\DAV\TokenScopes;
use Sabre\CardDAV\Plugin as CardDAVPlugin;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;

/**
* Limits app passwords with scopes to the DAV collections their scopes open
*/
class TokenScopePlugin extends ServerPlugin {
private const READ_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PROPFIND', 'REPORT', 'SEARCH'];

private const FILES_COLLECTIONS = ['files', 'uploads', 'bulk'];

private Server $server;

/**
* @param PluginManager|null $pluginManager null for servers without app collections
* @param string $prefix prepended to every path, for servers whose tree is a single collection
*/
public function __construct(
private LockdownManager $lockdownManager,
private ?PluginManager $pluginManager = null,
private string $prefix = '',
) {
}

#[\Override]
public function initialize(Server $server): void {
$this->server = $server;
// After authentication (10), before the ACL plugin (20)
$server->on('beforeMethod:*', [$this, 'beforeMethod'], 15);
}

public function beforeMethod(RequestInterface $request): void {
if (!$this->lockdownManager->isScoped()) {
return;
}

$method = $request->getMethod();
$destination = $method === 'COPY' || $method === 'MOVE' ? $request->getHeader('Destination') : null;
// COPY only reads its source
if (!$this->allows($request->getPath(), $method === 'COPY' ? 'GET' : $method)
|| ($destination !== null && !$this->allows($this->server->calculateUri($destination), $method))) {
throw new Forbidden('Not allowed by the app password scopes');
}

// Registered here so unscoped requests skip the per-node check. Priority 0 runs before any handler that loads data
$this->server->on('propFind', [$this, 'propFind'], 0);
}

/**
* Returning false hides the node from listings, multiget and expand-property
*/
public function propFind(PropFind $propFind, INode $node): ?bool {
return $this->allows($propFind->getPath(), 'PROPFIND') ? null : false;
}

public function allows(string $path, string $method): bool {
$segments = preg_split('#/+#', $this->prefix . $path, -1, PREG_SPLIT_NO_EMPTY);
$write = !in_array($method, self::READ_METHODS, true);

return match ($segments[0] ?? '') {
'' => !$write,
CalDAVPlugin::CALENDAR_ROOT, CalDAVPlugin::SYSTEM_CALENDAR_ROOT => $this->allowsGroupware($segments, $method, $write, TokenScopes::CALENDAR_READ, TokenScopes::CALENDAR_WRITE),
CardDAVPlugin::ADDRESSBOOK_ROOT => $this->allowsGroupware($segments, $method, $write, TokenScopes::CONTACTS_READ, TokenScopes::CONTACTS_WRITE),
'principals' => $write
// Proxy membership grants access to all calendars, which counts as sharing
? !in_array(end($segments), ['calendar-proxy-read', 'calendar-proxy-write'], true) && $this->lockdownManager->hasScope(TokenScopes::CALENDAR_WRITE)
: $this->lockdownManager->hasScope(TokenScopes::CALENDAR_READ) || $this->lockdownManager->hasScope(TokenScopes::CONTACTS_READ),
'avatars' => $method === 'GET' || $method === 'HEAD',
default => $this->allowsAppCollection($segments[0], $write),
};
}

/**
* Collections need their app's own DAV scopes. Apps register theirs in info.xml
*/
private function allowsAppCollection(string $name, bool $write): bool {
$app = in_array($name, self::FILES_COLLECTIONS, true) ? 'files' : $this->pluginManager?->getCollectionApp($name);
return $app !== null
&& ($this->lockdownManager->hasScope($app) || $this->lockdownManager->hasScope($app . ':dav:' . ($write ? 'write' : 'read')));
}

/**
* @param list<string> $segments
*/
private function allowsGroupware(array $segments, string $method, bool $write, string $readScope, string $writeScope): bool {
// POST shares and publishes calendars and address books, except on the scheduling outbox
if ($method === 'POST') {
return $segments[0] === CalDAVPlugin::CALENDAR_ROOT && count($segments) === 3 && $segments[2] === 'outbox'
&& $this->lockdownManager->hasScope($writeScope);
}

return $this->lockdownManager->hasScope($write ? $writeScope : $readScope);
}
}
10 changes: 5 additions & 5 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace OCA\DAV;

use OC\Files\Filesystem;
use OC\Lockdown\LockdownManager;
use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\BulkUpload\BulkUploadPlugin;
use OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin;
Expand Down Expand Up @@ -53,6 +54,7 @@
use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin;
use OCA\DAV\Connector\Sabre\SharesPlugin;
use OCA\DAV\Connector\Sabre\TagsPlugin;
use OCA\DAV\Connector\Sabre\TokenScopePlugin;
use OCA\DAV\Connector\Sabre\UserIdHeaderPlugin;
use OCA\DAV\Connector\Sabre\ZipFolderPlugin;
use OCA\DAV\DAV\CustomPropertiesBackend;
Expand Down Expand Up @@ -179,6 +181,8 @@ public function __construct(
$authPlugin->addBackend($bearerAuthBackend);
// because we are throwing exceptions this plugin has to be the last one
$authPlugin->addBackend($authBackend);
$pluginManager = new PluginManager(\OC::$server, \OCP\Server::get(IAppManager::class));
$this->server->addPlugin(new TokenScopePlugin(\OCP\Server::get(LockdownManager::class), $pluginManager));

// debugging
if ($debugEnabled) {
Expand Down Expand Up @@ -286,7 +290,7 @@ public function __construct(
$this->server->addPlugin(new SearchPlugin($lazySearchBackend));

// wait with registering these until auth is handled and the filesystem is setup
$this->server->on('beforeMethod:*', function () use ($root, $lazySearchBackend, $logger): void {
$this->server->on('beforeMethod:*', function () use ($root, $lazySearchBackend, $logger, $pluginManager): void {
// custom properties plugin must be the last one
$userSession = \OCP\Server::get(IUserSession::class);
$user = $userSession->getUser();
Expand Down Expand Up @@ -411,10 +415,6 @@ function () {
}

// register plugins from apps
$pluginManager = new PluginManager(
\OC::$server,
\OCP\Server::get(IAppManager::class)
);
foreach ($pluginManager->getAppPlugins() as $appPlugin) {
$this->server->addPlugin($appPlugin);
}
Expand Down
5 changes: 5 additions & 0 deletions apps/dav/tests/unit/AppInfo/PluginManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ public function test(): void {
$dummyCollection1 = $this->createMock(Collection::class);
$dummyCollection2 = $this->createMock(Collection::class);
$dummy2Collection1 = $this->createMock(Collection::class);
$dummyCollection1->method('getName')->willReturn('one');
$dummy2Collection1->method('getName')->willReturn('two');

$server->method('get')
->willReturnMap([
Expand Down Expand Up @@ -123,5 +125,8 @@ public function test(): void {
$this->assertEquals($expectedPlugins, $pluginManager->getAppPlugins());
$this->assertEquals($expectedCalendarPlugins, $pluginManager->getCalendarPlugins());
$this->assertEquals($expectedCollections, $pluginManager->getAppCollections());
$this->assertSame('adavapp', $pluginManager->getCollectionApp('one'));
$this->assertSame('adavapp2', $pluginManager->getCollectionApp('two'));
$this->assertNull($pluginManager->getCollectionApp('files'));
}
}
Loading
Loading