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
2 changes: 2 additions & 0 deletions apps/dav/appinfo/v1/caldav.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,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 @@ -110,6 +111,7 @@
// Add plugins
$server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), $davL10n));
$server->addPlugin(new \Sabre\DAV\Auth\Plugin($authBackend));
$server->addPlugin(Server::get(TokenScopePlugin::class));
$server->addPlugin(new \Sabre\CalDAV\Plugin());

$server->addPlugin(new LegacyDAVACL());
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/appinfo/v1/carddav.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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 +93,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(Server::get(TokenScopePlugin::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), '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
104 changes: 104 additions & 0 deletions apps/dav/lib/Connector/Sabre/TokenScopePlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?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\CalDAV\Plugin as CalDAVPlugin;
use OCA\DAV\TokenScopes;
use OCA\Files\TokenScopes as FilesTokenScopes;
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'];

/** Collections for files and their tags, plus bulk upload. Writes are guarded by the mount permissions */
private const FILES_COLLECTIONS = ['files', 'uploads', 'bulk', 'trashbin', 'versions', 'groupfolders', 'systemtags', 'systemtags-relations', 'systemtags-assigned'];

private Server $server;

/**
* @param string $prefix prepended to every path, for servers whose tree is a single collection
*/
public function __construct(
private LockdownManager $lockdownManager,
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 => in_array($segments[0], self::FILES_COLLECTIONS, true) && $this->lockdownManager->hasScope(FilesTokenScopes::FILES_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);
}
}
2 changes: 2 additions & 0 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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 +180,7 @@ public function __construct(
$authPlugin->addBackend($bearerAuthBackend);
// because we are throwing exceptions this plugin has to be the last one
$authPlugin->addBackend($authBackend);
$this->server->addPlugin(\OCP\Server::get(TokenScopePlugin::class));

// debugging
if ($debugEnabled) {
Expand Down
157 changes: 157 additions & 0 deletions apps/dav/tests/unit/Connector/Sabre/TokenScopePluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\Tests\unit\Connector\Sabre;

use OC\Authentication\Token\PublicKeyToken;
use OC\Authentication\Token\TokenScopes as CoreTokenScopes;
use OC\Lockdown\LockdownManager;
use OCA\DAV\Connector\Sabre\TokenScopePlugin;
use OCA\DAV\TokenScopes;
use OCA\Files\TokenScopes as FilesTokenScopes;
use OCP\ISession;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Server;
use Sabre\DAV\SimpleCollection;
use Sabre\HTTP\Request;
use Test\TestCase;

class TokenScopePluginTest extends TestCase {
private const FILES = [FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_WRITE];
private const CALENDAR = [TokenScopes::CALENDAR_READ, TokenScopes::CALENDAR_WRITE];
private const CALENDAR_READ = [TokenScopes::CALENDAR_READ];
private const CONTACTS = [TokenScopes::CONTACTS_READ, TokenScopes::CONTACTS_WRITE];
private const CONTACTS_READ = [TokenScopes::CONTACTS_READ];

/**
* @param list<string>|null $scopes null for a token without scopes
*/
private function plugin(?array $scopes, string $prefix = ''): TokenScopePlugin {
$lockdownManager = new LockdownManager(fn () => $this->createMock(ISession::class), new CoreTokenScopes());
$token = new PublicKeyToken();
$token->setScope($scopes === null ? [] : [CoreTokenScopes::KEY => $scopes]);
$lockdownManager->setToken($token);
return new TokenScopePlugin($lockdownManager, $prefix);
}

public static function dataAllows(): array {
return [
'root, any key' => ['', 'PROPFIND', [], true],
'root write' => ['', 'PROPPATCH', self::FILES, false],
'files' => ['files/user0/a.txt', 'GET', self::FILES, true],
'files write, mask guards it' => ['files/user0/a.txt', 'PUT', self::FILES, true],
'files without files' => ['files/user0', 'PROPFIND', self::CALENDAR, false],
'uploads' => ['uploads/user0/1', 'MKCOL', self::FILES, true],
'bulk upload' => ['bulk', 'POST', self::FILES, true],
'bulk upload without files' => ['bulk', 'POST', self::CALENDAR, false],
'groupfolder trash without files' => ['trashbin/user0/trash', 'PROPFIND', self::CALENDAR, false],
'groupfolders' => ['groupfolders/user0', 'PROPFIND', self::FILES, true],
'versions' => ['versions/user0/versions/1', 'PROPFIND', self::FILES, true],
'tag definitions read' => ['systemtags/1', 'PROPFIND', self::FILES, true],
'tag definitions write' => ['systemtags/1', 'PROPPATCH', self::FILES, true],
'tag definitions without files' => ['systemtags/1', 'PROPFIND', self::CALENDAR, false],
'tag assignment' => ['systemtags-relations/files/1/2', 'PUT', self::FILES, true],
'assigned tags' => ['systemtags-assigned/files', 'PROPFIND', self::FILES, true],
'comments' => ['comments/files/1', 'PROPFIND', self::FILES, false],
'calendar read' => ['calendars/user0/personal', 'REPORT', self::CALENDAR_READ, true],
'calendar write without write' => ['calendars/user0/personal/a.ics', 'PUT', self::CALENDAR_READ, false],
'calendar write' => ['calendars/user0/personal/a.ics', 'PUT', self::CALENDAR, true],
'calendar without calendar' => ['calendars/user0/personal', 'PROPFIND', self::FILES, false],
'calendar sharing' => ['calendars/user0/personal', 'POST', self::CALENDAR, false],
'scheduling outbox' => ['calendars/user0/outbox', 'POST', self::CALENDAR, true],
'scheduling outbox, doubled slashes' => ['calendars//user0//outbox', 'POST', self::CALENDAR, true],
'scheduling outbox without write' => ['calendars/user0/outbox', 'POST', self::CALENDAR_READ, false],
'room calendars' => ['system-calendars/calendar-rooms/room1', 'REPORT', self::CALENDAR, true],
'address book read' => ['addressbooks/users/user0/contacts', 'PROPFIND', self::CONTACTS_READ, true],
'address book write without write' => ['addressbooks/users/user0/contacts/a.vcf', 'PUT', self::CONTACTS_READ, false],
'address book write' => ['addressbooks/users/user0/contacts/a.vcf', 'PUT', self::CONTACTS, true],
'address book sharing' => ['addressbooks/users/user0/contacts', 'POST', self::CONTACTS, false],
'principals without groupware' => ['principals/users/user0', 'PROPFIND', self::FILES, false],
'principals with contacts' => ['principals/users/user0', 'PROPFIND', self::CONTACTS_READ, true],
'principals with calendar' => ['principals/users/user0', 'PROPFIND', self::CALENDAR_READ, true],
'default calendar with contacts' => ['principals/users/user0', 'PROPPATCH', self::CONTACTS, false],
'default calendar with calendar' => ['principals/users/user0', 'PROPPATCH', self::CALENDAR, true],
'proxy write' => ['principals/users/user0/calendar-proxy-write', 'PROPPATCH', self::CALENDAR, false],
'proxy read' => ['principals/users/user0/calendar-proxy-read/', 'PROPPATCH', self::CALENDAR, false],
'proxy listing' => ['principals/users/user0/calendar-proxy-write', 'PROPFIND', self::CALENDAR_READ, true],
'avatar' => ['avatars/user0/96.png', 'GET', [], true],
'avatar listing' => ['avatars/user0', 'PROPFIND', [], false],
'provisioning' => ['provisioning/apple-provisioning.mobileconfig', 'GET', self::CALENDAR, false],
'public calendars' => ['public-calendars/abc', 'GET', self::CALENDAR, false],
'remote calendars' => ['remote-calendars/abc', 'PROPFIND', self::CALENDAR, false],
'app collection' => ['photos/user0/albums', 'PROPFIND', self::FILES, false],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataAllows')]
public function testAllows(string $path, string $method, array $scopes, bool $expected): void {
$this->assertSame($expected, $this->plugin($scopes)->allows($path, $method));
}

public function testLegacyWebdavIsFiles(): void {
$this->assertTrue($this->plugin(self::FILES, 'files/')->allows('Documents', 'PROPFIND'));
$this->assertFalse($this->plugin(self::CALENDAR, 'files/')->allows('Documents', 'PROPFIND'));
}

private function initialize(TokenScopePlugin $plugin): Server {
$server = new Server(new SimpleCollection('root', [
new SimpleCollection('calendars', [new SimpleCollection('user0', [new SimpleCollection('personal')])]),
new SimpleCollection('comments', [new SimpleCollection('files')]),
]));
$server->setBaseUri('/');
$plugin->initialize($server);
return $server;
}

public function testRefusesRequest(): void {
$server = $this->initialize($this->plugin(self::FILES));

$this->expectException(Forbidden::class);
$server->emit('beforeMethod:PROPFIND', [new Request('PROPFIND', '/calendars/user0/personal'), $server->httpResponse]);
}

public function testRefusesEncodedSegment(): void {
$server = $this->initialize($this->plugin(self::FILES));

$this->expectException(Forbidden::class);
$server->emit('beforeMethod:GET', [new Request('GET', '/%63alendars/user0/personal'), $server->httpResponse]);
}

public function testRefusesCopyIntoCalendar(): void {
$server = $this->initialize($this->plugin(self::FILES));
$request = new Request('COPY', '/files/user0/a.ics', ['Destination' => '/calendars/user0/personal/a.ics']);

$this->expectException(Forbidden::class);
$server->emit('beforeMethod:COPY', [$request, $server->httpResponse]);
}

public function testCopyOnlyReadsItsSource(): void {
$server = $this->initialize($this->plugin([...self::CALENDAR_READ, ...self::FILES]));
$request = new Request('COPY', '/calendars/user0/personal/a.ics', ['Destination' => '/files/user0/a.ics']);

$this->assertTrue($server->emit('beforeMethod:COPY', [$request, $server->httpResponse]));
}

public function testHidesNodesInMultistatus(): void {
$server = $this->initialize($this->plugin(self::CALENDAR));
$server->emit('beforeMethod:REPORT', [new Request('REPORT', '/calendars/user0/personal'), $server->httpResponse]);

$result = $server->getPropertiesForMultiplePaths(['calendars/user0/personal', 'comments/files'], ['{DAV:}resourcetype']);

$this->assertSame(['calendars/user0/personal'], array_keys($result));
}

public function testUnscopedTokenPasses(): void {
$server = $this->initialize($this->plugin(null));

$this->assertTrue($server->emit('beforeMethod:PROPFIND', [new Request('PROPFIND', '/comments/files'), $server->httpResponse]));
$result = $server->getPropertiesForMultiplePaths(['calendars/user0/personal', 'comments/files'], ['{DAV:}resourcetype']);
$this->assertCount(2, $result);
}
}
40 changes: 40 additions & 0 deletions build/integration/features/auth.feature
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,43 @@ Feature: auth
Given a new client token limited to "files:read files:write" is added
When requesting "/index.php/apps/files" with "GET" using scoped basic token auth
Then the HTTP status code should be "403"

Scenario: a files token reaches files over DAV
Given a new client token limited to "files:read files:write" is added
When requesting "/remote.php/dav/files/user0/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "207"

Scenario: a files token reads no calendars over DAV
Given a new client token limited to "files:read files:write" is added
When requesting "/remote.php/dav/calendars/user0/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "403"

Scenario: a files token reaches files over legacy WebDAV
Given a new client token limited to "files:read files:write" is added
When requesting "/remote.php/webdav/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "207"

Scenario: a files token reads no calendars over legacy CalDAV
Given a new client token limited to "files:read files:write" is added
When requesting "/remote.php/caldav/calendars/user0/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "403"

Scenario: a calendar token reaches calendars over DAV
Given a new client token limited to "calendar:read calendar:write" is added
When requesting "/remote.php/dav/calendars/user0/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "207"

Scenario: a calendar token reads no files over DAV
Given a new client token limited to "calendar:read calendar:write" is added
When requesting "/remote.php/dav/files/user0/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "403"

Scenario: a calendar token reads no files over legacy WebDAV
Given a new client token limited to "calendar:read calendar:write" is added
When requesting "/remote.php/webdav/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "403"

Scenario: a calendar token cannot list the trash
Given a new client token limited to "calendar:read calendar:write" is added
When requesting "/remote.php/dav/trashbin/user0/trash/" with "PROPFIND" using scoped basic token auth
Then the HTTP status code should be "403"