diff --git a/apps/dav/appinfo/v1/caldav.php b/apps/dav/appinfo/v1/caldav.php index 9547ff88c8dc9..cf6f935da4939 100644 --- a/apps/dav/appinfo/v1/caldav.php +++ b/apps/dav/appinfo/v1/caldav.php @@ -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; @@ -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()); diff --git a/apps/dav/appinfo/v1/carddav.php b/apps/dav/appinfo/v1/carddav.php index 4f5218855235e..36d2e03234de3 100644 --- a/apps/dav/appinfo/v1/carddav.php +++ b/apps/dav/appinfo/v1/carddav.php @@ -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; @@ -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()); diff --git a/apps/dav/appinfo/v1/webdav.php b/apps/dav/appinfo/v1/webdav.php index 5453c0a8bfb05..de90baf3a1db0 100644 --- a/apps/dav/appinfo/v1/webdav.php +++ b/apps/dav/appinfo/v1/webdav.php @@ -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; @@ -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); diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 37fc96d646a4f..893d2d765f5f6 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -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', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 9470519d45627..0f2a67eef64fe 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -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', diff --git a/apps/dav/lib/Connector/Sabre/TokenScopePlugin.php b/apps/dav/lib/Connector/Sabre/TokenScopePlugin.php new file mode 100644 index 0000000000000..22e70ba87365a --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/TokenScopePlugin.php @@ -0,0 +1,104 @@ +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 $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); + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index e80229b2e3398..66f7a3e284a76 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -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; @@ -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) { diff --git a/apps/dav/tests/unit/Connector/Sabre/TokenScopePluginTest.php b/apps/dav/tests/unit/Connector/Sabre/TokenScopePluginTest.php new file mode 100644 index 0000000000000..389264a056eaa --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/TokenScopePluginTest.php @@ -0,0 +1,157 @@ +|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); + } +} diff --git a/build/integration/features/auth.feature b/build/integration/features/auth.feature index c924c90ef2ce3..ef6b0beff5ca2 100644 --- a/build/integration/features/auth.feature +++ b/build/integration/features/auth.feature @@ -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"