diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index a15bb5d49e352..36779c3833f85 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -460,6 +460,7 @@ 'OCA\\DAV\\SystemTag\\SystemTagsObjectMappingCollection' => $baseDir . '/../lib/SystemTag/SystemTagsObjectMappingCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsObjectTypeCollection' => $baseDir . '/../lib/SystemTag/SystemTagsObjectTypeCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsRelationsCollection' => $baseDir . '/../lib/SystemTag/SystemTagsRelationsCollection.php', + 'OCA\\DAV\\TokenScopes' => $baseDir . '/../lib/TokenScopes.php', 'OCA\\DAV\\Traits\\PrincipalProxyTrait' => $baseDir . '/../lib/Traits/PrincipalProxyTrait.php', 'OCA\\DAV\\Upload\\AssemblyStream' => $baseDir . '/../lib/Upload/AssemblyStream.php', 'OCA\\DAV\\Upload\\ChunkingPlugin' => $baseDir . '/../lib/Upload/ChunkingPlugin.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 16788a2db444a..13397cb409415 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -475,6 +475,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\SystemTag\\SystemTagsObjectMappingCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsObjectMappingCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsObjectTypeCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsObjectTypeCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsRelationsCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsRelationsCollection.php', + 'OCA\\DAV\\TokenScopes' => __DIR__ . '/..' . '/../lib/TokenScopes.php', 'OCA\\DAV\\Traits\\PrincipalProxyTrait' => __DIR__ . '/..' . '/../lib/Traits/PrincipalProxyTrait.php', 'OCA\\DAV\\Upload\\AssemblyStream' => __DIR__ . '/..' . '/../lib/Upload/AssemblyStream.php', 'OCA\\DAV\\Upload\\ChunkingPlugin' => __DIR__ . '/..' . '/../lib/Upload/ChunkingPlugin.php', diff --git a/apps/dav/lib/Controller/CalendarExportController.php b/apps/dav/lib/Controller/CalendarExportController.php index cd66c139f89ed..7cffdfc3b1281 100644 --- a/apps/dav/lib/Controller/CalendarExportController.php +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -8,8 +8,10 @@ namespace OCA\DAV\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\Export\ExportService; +use OCA\DAV\TokenScopes; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -55,6 +57,7 @@ public function __construct( #[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')] #[UserRateLimit(limit: 1, period: 60)] #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_READ)] public function export(string $target, ?string $type = null, ?array $options = null, ?string $user = null) { $calendarId = $target; $format = $type ?? 'ical'; diff --git a/apps/dav/lib/Controller/CalendarImportController.php b/apps/dav/lib/Controller/CalendarImportController.php index 83c40fa5651b4..f47c8c88bf91e 100644 --- a/apps/dav/lib/Controller/CalendarImportController.php +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -9,12 +9,14 @@ namespace OCA\DAV\Controller; use InvalidArgumentException; +use NCU\AppFramework\Http\Attribute\TokenScope; /** * @psalm-type CalendarImportResult = array{items: list, total: non-negative-int} */ use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\CalendarImpl; use OCA\DAV\CalDAV\Import\ImportService; +use OCA\DAV\TokenScopes; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -62,6 +64,7 @@ public function __construct( #[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] #[UserRateLimit(limit: 10, period: 3600)] #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_WRITE)] public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamTraversableResponse { $calendarId = $target; $format = isset($options['format']) ? $options['format'] : null; diff --git a/apps/dav/lib/Controller/ContactsImportController.php b/apps/dav/lib/Controller/ContactsImportController.php index fe1264a5f3b1d..11f658befadd3 100644 --- a/apps/dav/lib/Controller/ContactsImportController.php +++ b/apps/dav/lib/Controller/ContactsImportController.php @@ -9,11 +9,13 @@ namespace OCA\DAV\Controller; use InvalidArgumentException; +use NCU\AppFramework\Http\Attribute\TokenScope; /** * @psalm-type ContactsImportResult = array{items: list, total: non-negative-int} */ use OCA\DAV\AppInfo\Application; use OCA\DAV\CardDAV\Import\ImportService; +use OCA\DAV\TokenScopes; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -62,6 +64,7 @@ public function __construct( #[ApiRoute(verb: 'POST', url: '/import', root: '/contacts')] #[UserRateLimit(limit: 10, period: 3600)] #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CONTACTS_WRITE)] public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamTraversableResponse { $addressBookId = $target; $format = isset($options['format']) ? $options['format'] : null; diff --git a/apps/dav/lib/Controller/DirectController.php b/apps/dav/lib/Controller/DirectController.php index 7ccdcdcc34dd9..7934a2db32e10 100644 --- a/apps/dav/lib/Controller/DirectController.php +++ b/apps/dav/lib/Controller/DirectController.php @@ -9,6 +9,7 @@ namespace OCA\DAV\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OCA\DAV\Db\Direct; use OCA\DAV\Db\DirectMapper; use OCP\AppFramework\Http; @@ -58,6 +59,8 @@ public function __construct( * 200: Direct link returned */ #[NoAdminRequired] + // Not OCA\Files_Sharing\TokenScopes::SHARES_WRITE: files_sharing can be disabled, and its classes don't load then + #[TokenScope(scope: 'files_sharing:shares:write')] public function getUrl(int $fileId, int $expirationTime = 60 * 60 * 8): DataResponse { if (!$this->shareManager->shareApiAllowLinks()) { throw new OCSForbiddenException('Creating direct links is disabled'); diff --git a/apps/dav/lib/Controller/FederatedCalendarController.php b/apps/dav/lib/Controller/FederatedCalendarController.php index cd674f6006a08..7f565a4af14c0 100644 --- a/apps/dav/lib/Controller/FederatedCalendarController.php +++ b/apps/dav/lib/Controller/FederatedCalendarController.php @@ -9,11 +9,13 @@ namespace OCA\DAV\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity; use OCA\DAV\CalDAV\Federation\FederatedCalendarInvitationService; use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper; use OCA\DAV\ResponseDefinitions; +use OCA\DAV\TokenScopes; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -44,6 +46,7 @@ public function __construct( * 401: When the user is not logged in */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_READ)] public function getPending(): DataResponse { if ($this->userId === null) { return new DataResponse(null, Http::STATUS_UNAUTHORIZED); @@ -76,6 +79,7 @@ public function getPending(): DataResponse { * 200: Invitation accepted successfully */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_WRITE)] public function accept(int $id): DataResponse { $this->invitationService->accept($this->getPendingCalendar($id)); return new DataResponse(null); @@ -91,6 +95,7 @@ public function accept(int $id): DataResponse { * 200: Invitation declined successfully */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_WRITE)] public function decline(int $id): DataResponse { $this->invitationService->decline($this->getPendingCalendar($id)); return new DataResponse(null); diff --git a/apps/dav/lib/Controller/OutOfOfficeController.php b/apps/dav/lib/Controller/OutOfOfficeController.php index d3516d092e8cd..5b917f2ac2831 100644 --- a/apps/dav/lib/Controller/OutOfOfficeController.php +++ b/apps/dav/lib/Controller/OutOfOfficeController.php @@ -10,8 +10,10 @@ namespace OCA\DAV\Controller; use DateTimeImmutable; +use NCU\AppFramework\Http\Attribute\TokenScope; use OCA\DAV\ResponseDefinitions; use OCA\DAV\Service\AbsenceService; +use OCA\DAV\TokenScopes; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -50,6 +52,7 @@ public function __construct( * 404: No out-of-office data was found */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_READ)] public function getCurrentOutOfOfficeData(string $userId): DataResponse { $user = $this->userManager->get($userId); if ($user === null) { @@ -77,6 +80,7 @@ public function getCurrentOutOfOfficeData(string $userId): DataResponse { * 404: No out-of-office data was found */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_READ)] public function getOutOfOffice(string $userId): DataResponse { try { $data = $this->absenceService->getAbsence($userId); @@ -115,6 +119,7 @@ public function getOutOfOffice(string $userId): DataResponse { * 404: When the replacementUserId was provided but replacement user was not found */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_WRITE)] public function setOutOfOffice( string $firstDay, string $lastDay, @@ -176,6 +181,7 @@ public function setOutOfOffice( * 401: When the user is not logged in */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_WRITE)] public function clearOutOfOffice(): DataResponse { $user = $this->userSession?->getUser(); if ($user === null) { diff --git a/apps/dav/lib/Controller/UpcomingEventsController.php b/apps/dav/lib/Controller/UpcomingEventsController.php index a5d54f4475445..60c480d65a1e2 100644 --- a/apps/dav/lib/Controller/UpcomingEventsController.php +++ b/apps/dav/lib/Controller/UpcomingEventsController.php @@ -9,10 +9,12 @@ namespace OCA\DAV\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\UpcomingEvent; use OCA\DAV\CalDAV\UpcomingEventsService; use OCA\DAV\ResponseDefinitions; +use OCA\DAV\TokenScopes; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; @@ -41,6 +43,7 @@ public function __construct( * 401: When not authenticated */ #[NoAdminRequired] + #[TokenScope(scope: TokenScopes::CALENDAR_READ)] public function getEvents(?string $location = null): DataResponse { if ($this->userId === null) { return new DataResponse(null, Http::STATUS_UNAUTHORIZED); diff --git a/apps/dav/lib/TokenScopes.php b/apps/dav/lib/TokenScopes.php new file mode 100644 index 0000000000000..f7510d2570d80 --- /dev/null +++ b/apps/dav/lib/TokenScopes.php @@ -0,0 +1,20 @@ +shareManager->getShareById($shareId); diff --git a/apps/files_sharing/lib/Controller/DeletedShareAPIController.php b/apps/files_sharing/lib/Controller/DeletedShareAPIController.php index 58d0cad848169..c7250a22cde70 100644 --- a/apps/files_sharing/lib/Controller/DeletedShareAPIController.php +++ b/apps/files_sharing/lib/Controller/DeletedShareAPIController.php @@ -9,7 +9,9 @@ namespace OCA\Files_Sharing\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OCA\Deck\Sharing\ShareAPIHelper; +use OCA\Files\TokenScopes; use OCA\Files_Sharing\ResponseDefinitions; use OCP\App\IAppManager; use OCP\AppFramework\Http; @@ -35,6 +37,7 @@ /** * @psalm-import-type Files_SharingDeletedShare from ResponseDefinitions */ +#[TokenScope(scope: TokenScopes::FILES_WRITE)] class DeletedShareAPIController extends OCSController { public function __construct( diff --git a/apps/files_sharing/lib/Controller/ExternalSharesController.php b/apps/files_sharing/lib/Controller/ExternalSharesController.php index c297b9e017900..664ca5ad0755c 100644 --- a/apps/files_sharing/lib/Controller/ExternalSharesController.php +++ b/apps/files_sharing/lib/Controller/ExternalSharesController.php @@ -8,6 +8,8 @@ namespace OCA\Files_Sharing\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCA\Files_Sharing\BackgroundJob\ExternalShareScanJob; use OCA\Files_Sharing\External\Manager; use OCP\AppFramework\Controller; @@ -24,6 +26,7 @@ * * @package OCA\Files_Sharing\Controller */ +#[TokenScope(scope: TokenScopes::FILES_WRITE)] class ExternalSharesController extends Controller { public function __construct( string $appName, diff --git a/apps/files_sharing/lib/Controller/PublicPreviewController.php b/apps/files_sharing/lib/Controller/PublicPreviewController.php index 64ab6eec4c20c..1ec86212ef8ec 100644 --- a/apps/files_sharing/lib/Controller/PublicPreviewController.php +++ b/apps/files_sharing/lib/Controller/PublicPreviewController.php @@ -7,6 +7,8 @@ namespace OCA\Files_Sharing\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoSameSiteCookieRequired; @@ -29,6 +31,7 @@ use OCP\Share\IManager as ShareManager; use OCP\Share\IShare; +#[TokenScope(scope: TokenScopes::FILES_READ)] class PublicPreviewController extends PublicShareController { private IShare $share; diff --git a/apps/files_sharing/lib/Controller/RemoteController.php b/apps/files_sharing/lib/Controller/RemoteController.php index adc37a5bec0ee..ae1fe5c5e098d 100644 --- a/apps/files_sharing/lib/Controller/RemoteController.php +++ b/apps/files_sharing/lib/Controller/RemoteController.php @@ -8,6 +8,8 @@ namespace OCA\Files_Sharing\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCA\Files_Sharing\External\ExternalShare; use OCA\Files_Sharing\External\Manager; use OCA\Files_Sharing\ResponseDefinitions; @@ -28,6 +30,7 @@ * @psalm-import-type Files_SharingRemoteShare from ResponseDefinitions * @api */ +#[TokenScope(scope: TokenScopes::FILES_WRITE)] class RemoteController extends OCSController { /** * Remote controller constructor. diff --git a/apps/files_sharing/lib/Controller/SettingsController.php b/apps/files_sharing/lib/Controller/SettingsController.php index 615b5faf1c87e..c52c61e69b05f 100644 --- a/apps/files_sharing/lib/Controller/SettingsController.php +++ b/apps/files_sharing/lib/Controller/SettingsController.php @@ -9,6 +9,8 @@ namespace OCA\Files_Sharing\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCA\Files_Sharing\AppInfo\Application; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -16,6 +18,7 @@ use OCP\IConfig; use OCP\IRequest; +#[TokenScope(scope: TokenScopes::FILES_WRITE)] class SettingsController extends Controller { public function __construct( diff --git a/apps/files_sharing/lib/Controller/ShareAPIController.php b/apps/files_sharing/lib/Controller/ShareAPIController.php index 854c92aad0060..58f4573986e8f 100644 --- a/apps/files_sharing/lib/Controller/ShareAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareAPIController.php @@ -10,16 +10,19 @@ namespace OCA\Files_Sharing\Controller; use Exception; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\Core\AppInfo\ConfigLexicon; use OC\Files\FileInfo; use OC\Files\Storage\Wrapper\Wrapper; use OCA\Circles\Api\v1\Circles; use OCA\Deck\Sharing\ShareAPIHelper; use OCA\Federation\TrustedServers; +use OCA\Files\TokenScopes as FilesTokenScopes; use OCA\Files_Sharing\Exceptions\SharingRightsException; use OCA\Files_Sharing\External\Storage; use OCA\Files_Sharing\ResponseDefinitions; use OCA\Files_Sharing\SharedStorage; +use OCA\Files_Sharing\TokenScopes as SharingTokenScopes; use OCA\GlobalSiteSelector\Service\SlaveService; use OCP\App\IAppManager; use OCP\AppFramework\Http; @@ -74,6 +77,7 @@ * * @psalm-import-type Files_SharingShare from ResponseDefinitions */ +#[TokenScope(scope: FilesTokenScopes::FILES_WRITE)] class ShareAPIController extends OCSController { /** Maximum length of a custom share token, matching the oc_share.token database column. */ @@ -547,6 +551,7 @@ public function getShare(string $id, bool $include_tags = false): DataResponse { * 200: Share deleted successfully */ #[NoAdminRequired] + #[TokenScope(scope: SharingTokenScopes::SHARES_WRITE)] public function deleteShare(string $id): DataResponse { try { $share = $this->getShareById($id); @@ -611,6 +616,7 @@ public function deleteShare(string $id): DataResponse { */ #[NoAdminRequired] #[UserRateLimit(limit: 60, period: 600)] + #[TokenScope(scope: SharingTokenScopes::SHARES_WRITE)] public function createShare( ?string $path = null, ?int $permissions = null, @@ -1247,6 +1253,7 @@ private function hasPermission(int $permissionsSet, int $permissionsToCheck): bo * 200: Share updated successfully */ #[NoAdminRequired] + #[TokenScope(scope: SharingTokenScopes::SHARES_WRITE)] public function updateShare( string $id, ?int $permissions = null, @@ -2160,6 +2167,7 @@ private function checkInheritedAttributes(IShare $share): void { */ #[NoAdminRequired] #[UserRateLimit(limit: 10, period: 600)] + #[TokenScope(scope: SharingTokenScopes::SHARES_WRITE)] public function sendShareEmail(string $id, $password = ''): DataResponse { try { $share = $this->getShareById($id); diff --git a/apps/files_sharing/lib/Controller/ShareController.php b/apps/files_sharing/lib/Controller/ShareController.php index 38a355bda5d39..6383f29d6098c 100644 --- a/apps/files_sharing/lib/Controller/ShareController.php +++ b/apps/files_sharing/lib/Controller/ShareController.php @@ -8,9 +8,11 @@ namespace OCA\Files_Sharing\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\ServerNotAvailableException; use OCA\DAV\Connector\Sabre\PublicAuth; use OCA\FederatedFileSharing\FederatedShareProvider; +use OCA\Files\TokenScopes; use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Files_Sharing\Event\ShareLinkAccessedEvent; use OCP\Accounts\IAccountManager; @@ -329,6 +331,7 @@ public function showShare($path = ''): TemplateResponse { #[PublicPage] #[NoCSRFRequired] #[NoSameSiteCookieRequired] + #[TokenScope(scope: TokenScopes::FILES_READ)] public function downloadShare(string $token, ?string $files = null, string $path = ''): NotFoundResponse|RedirectResponse|DataResponse { $this->userSession->setIncognitoMode(true); diff --git a/apps/files_sharing/lib/Controller/ShareInfoController.php b/apps/files_sharing/lib/Controller/ShareInfoController.php index 1265256d7a6c2..fa3afcd964847 100644 --- a/apps/files_sharing/lib/Controller/ShareInfoController.php +++ b/apps/files_sharing/lib/Controller/ShareInfoController.php @@ -7,6 +7,8 @@ namespace OCA\Files_Sharing\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCA\Files_Sharing\ResponseDefinitions; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; @@ -27,6 +29,7 @@ /** * @psalm-import-type Files_SharingShareInfo from ResponseDefinitions */ +#[TokenScope(scope: TokenScopes::FILES_READ)] class ShareInfoController extends ApiController { /** diff --git a/apps/files_sharing/lib/Controller/ShareesAPIController.php b/apps/files_sharing/lib/Controller/ShareesAPIController.php index 666ee52e2bd2e..25ca3e3cac9e1 100644 --- a/apps/files_sharing/lib/Controller/ShareesAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareesAPIController.php @@ -10,9 +10,11 @@ namespace OCA\Files_Sharing\Controller; use Generator; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\Collaboration\Collaborators\SearchResult; use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\Files_Sharing\ResponseDefinitions; +use OCA\Files_Sharing\TokenScopes; use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -38,6 +40,7 @@ * @psalm-import-type Files_SharingShareesSearchResult from ResponseDefinitions * @psalm-import-type Files_SharingShareesRecommendedResult from ResponseDefinitions */ +#[TokenScope(scope: TokenScopes::SHARES_WRITE)] class ShareesAPIController extends OCSController { protected int $offset = 0; diff --git a/apps/files_trashbin/lib/Controller/PreviewController.php b/apps/files_trashbin/lib/Controller/PreviewController.php index 3ca66ec3047b8..b88d2de92e635 100644 --- a/apps/files_trashbin/lib/Controller/PreviewController.php +++ b/apps/files_trashbin/lib/Controller/PreviewController.php @@ -9,6 +9,8 @@ namespace OCA\Files_Trashbin\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCA\Files_Trashbin\Trash\ITrashManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -27,6 +29,7 @@ use OCP\IUserSession; #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] +#[TokenScope(scope: TokenScopes::FILES_READ)] class PreviewController extends Controller { public function __construct( string $appName, diff --git a/apps/files_versions/lib/Controller/PreviewController.php b/apps/files_versions/lib/Controller/PreviewController.php index 126506dbd5818..6a6a1fedfba4f 100644 --- a/apps/files_versions/lib/Controller/PreviewController.php +++ b/apps/files_versions/lib/Controller/PreviewController.php @@ -7,6 +7,8 @@ namespace OCA\Files_Versions\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCA\Files_Versions\Versions\IVersionManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -24,6 +26,7 @@ use OCP\Preview\IMimeIconProvider; #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] +#[TokenScope(scope: TokenScopes::FILES_READ)] class PreviewController extends Controller { public function __construct( diff --git a/build/integration/features/auth.feature b/build/integration/features/auth.feature index 4601221c808a9..cdcf0a720779b 100644 --- a/build/integration/features/auth.feature +++ b/build/integration/features/auth.feature @@ -113,3 +113,29 @@ Feature: auth Scenario: Creating an unrestricted auth token with regular login should work When a new unrestricted client token is added Then the HTTP status code should be "200" + + # SCOPED TOKENS + Scenario: a files token reaches the files OCS API + Given a new client token limited to "files:files:read files:files:write" is added + When requesting "/ocs/v2.php/apps/files_sharing/api/v1/shares" with "GET" using scoped basic token auth + Then the HTTP status code should be "200" + + Scenario: a files token is refused on capabilities + Given a new client token limited to "files:files:read files:files:write" is added + When requesting "/ocs/v2.php/cloud/capabilities" with "GET" using scoped basic token auth + Then the HTTP status code should be "403" + + Scenario: a files token without files_sharing:shares:write cannot create shares + Given a new client token limited to "files:files:read files:files:write" is added + When requesting "/ocs/v2.php/apps/files_sharing/api/v1/shares" with "POST" using scoped basic token auth + Then the HTTP status code should be "403" + + Scenario: a files token is refused on unified search + Given a new client token limited to "files:files:read files:files:write" is added + When requesting "/ocs/v2.php/search/providers" with "GET" using scoped basic token auth + Then the HTTP status code should be "403" + + Scenario: a files token gets no page render + Given a new client token limited to "files:files:read files: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" diff --git a/build/integration/features/bootstrap/Auth.php b/build/integration/features/bootstrap/Auth.php index a3f549769f2dc..de044003ac078 100644 --- a/build/integration/features/bootstrap/Auth.php +++ b/build/integration/features/bootstrap/Auth.php @@ -9,6 +9,7 @@ use GuzzleHttp\Cookie\CookieJar; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; +use PHPUnit\Framework\Assert; require __DIR__ . '/autoload.php'; @@ -17,6 +18,8 @@ trait Auth { private $unrestrictedClientToken; /** @var string */ private $restrictedClientToken; + /** @var string */ + private $scopedClientToken; /** @var Client */ private $client; /** @var string */ @@ -106,8 +109,22 @@ private function createClientToken($loginViaWeb = true) { */ public function aNewRestrictedClientTokenIsAdded() { $tokenObj = $this->createClientToken(); - $newCreatedTokenId = $tokenObj->deviceToken->id; - $fullUrl = substr($this->baseUrl, 0, -5) . '/index.php/settings/personal/authtokens/' . $newCreatedTokenId; + $this->updateClientTokenScope($tokenObj->deviceToken->id, ['filesystem' => false]); + $this->restrictedClientToken = $tokenObj->token; + } + + /** + * @Given a new client token limited to :scopes is added + */ + public function aNewClientTokenLimitedToIsAdded(string $scopes): void { + $tokenObj = $this->createClientToken(); + $this->updateClientTokenScope($tokenObj->deviceToken->id, ['scopes' => explode(' ', $scopes)]); + Assert::assertEquals(200, $this->response->getStatusCode()); + $this->scopedClientToken = $tokenObj->token; + } + + private function updateClientTokenScope(int $id, array $scope): void { + $fullUrl = substr($this->baseUrl, 0, -5) . '/index.php/settings/personal/authtokens/' . $id; $client = new Client(); $options = [ 'auth' => ['user0', '123456'], @@ -116,14 +133,11 @@ public function aNewRestrictedClientTokenIsAdded() { ], 'json' => [ 'name' => md5(microtime()), - 'scope' => [ - 'filesystem' => false, - ], + 'scope' => $scope, ], 'cookies' => $this->cookieJar, ]; $this->response = $client->request('PUT', $fullUrl, $options); - $this->restrictedClientToken = $tokenObj->token; } /** @@ -170,6 +184,15 @@ public function requestingWithRestrictedBasicTokenAuth($url, $method) { $this->sendRequest($url, $method, 'basic ' . base64_encode('user0:' . $this->restrictedClientToken), true); } + /** + * @When requesting :url with :method using scoped basic token auth + * + * No cookies: the browser session that created the token would win over it + */ + public function requestingWithScopedBasicTokenAuth(string $url, string $method): void { + $this->sendRequest($url, $method, 'basic ' . base64_encode('user0:' . $this->scopedClientToken)); + } + /** * @When requesting :url with :method using an unrestricted client token * diff --git a/core/Controller/AppPasswordController.php b/core/Controller/AppPasswordController.php index faa26a31913f7..beab42394abb6 100644 --- a/core/Controller/AppPasswordController.php +++ b/core/Controller/AppPasswordController.php @@ -9,6 +9,7 @@ namespace OC\Core\Controller; +use OC\AppFramework\Http\Attributes\NoTokenScopeRequired; use OC\Authentication\Events\AppPasswordCreatedEvent; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; @@ -120,6 +121,7 @@ public function getAppPassword(): DataResponse { */ #[NoAdminRequired] #[ApiRoute(verb: 'DELETE', url: '/apppassword', root: '/core')] + #[NoTokenScopeRequired] public function deleteAppPassword(): DataResponse { if (!$this->session->exists('app_password')) { throw new OCSForbiddenException('no app password in use'); @@ -147,6 +149,7 @@ public function deleteAppPassword(): DataResponse { */ #[NoAdminRequired] #[ApiRoute(verb: 'POST', url: '/apppassword/rotate', root: '/core')] + #[NoTokenScopeRequired] public function rotateAppPassword(): DataResponse { if (!$this->session->exists('app_password')) { throw new OCSForbiddenException('no app password in use'); diff --git a/core/Controller/PreviewController.php b/core/Controller/PreviewController.php index d4bb3e5f0955f..cf027d966ac8f 100644 --- a/core/Controller/PreviewController.php +++ b/core/Controller/PreviewController.php @@ -9,6 +9,8 @@ namespace OC\Core\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; +use OCA\Files\TokenScopes; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; @@ -62,6 +64,7 @@ public function __construct( #[NoCSRFRequired] #[FrontpageRoute(verb: 'GET', url: '/core/preview.png')] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + #[TokenScope(scope: TokenScopes::FILES_READ)] public function getPreview( string $file = '', int $x = 32, @@ -105,6 +108,7 @@ public function getPreview( #[NoCSRFRequired] #[FrontpageRoute(verb: 'GET', url: '/core/preview')] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + #[TokenScope(scope: TokenScopes::FILES_READ)] public function getPreviewByFileId( int $fileId, int $x = 32, diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index d9349026b8639..8172a8d97e45f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -7,6 +7,7 @@ return array( 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', + 'NCU\\AppFramework\\Http\\Attribute\\TokenScope' => $baseDir . '/lib/unstable/AppFramework/Http/Attribute/TokenScope.php', 'NCU\\Config\\Exceptions\\IncorrectTypeException' => $baseDir . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php', 'NCU\\Config\\Exceptions\\TypeConflictException' => $baseDir . '/lib/unstable/Config/Exceptions/TypeConflictException.php', 'NCU\\Config\\Exceptions\\UnknownKeyException' => $baseDir . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', @@ -1221,6 +1222,7 @@ 'OC\\AppFramework\\DependencyInjection\\DIContainer' => $baseDir . '/lib/private/AppFramework/DependencyInjection/DIContainer.php', 'OC\\AppFramework\\Http' => $baseDir . '/lib/private/AppFramework/Http.php', 'OC\\AppFramework\\Http\\Attributes\\FederationRateLimit' => $baseDir . '/lib/private/AppFramework/Http/Attributes/FederationRateLimit.php', + 'OC\\AppFramework\\Http\\Attributes\\NoTokenScopeRequired' => $baseDir . '/lib/private/AppFramework/Http/Attributes/NoTokenScopeRequired.php', 'OC\\AppFramework\\Http\\Attributes\\TwoFactorSetUpDoneRequired' => $baseDir . '/lib/private/AppFramework/Http/Attributes/TwoFactorSetUpDoneRequired.php', 'OC\\AppFramework\\Http\\Dispatcher' => $baseDir . '/lib/private/AppFramework/Http/Dispatcher.php', 'OC\\AppFramework\\Http\\Output' => $baseDir . '/lib/private/AppFramework/Http/Output.php', @@ -1248,11 +1250,13 @@ 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\NotLoggedInException' => $baseDir . '/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php', 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\SecurityException' => $baseDir . '/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php', 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\StrictCookieMissingException' => $baseDir . '/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php', + 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\TokenScopeException' => $baseDir . '/lib/private/AppFramework/Middleware/Security/Exceptions/TokenScopeException.php', 'OC\\AppFramework\\Middleware\\Security\\FeaturePolicyMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\PasswordConfirmationMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\RateLimitingMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\SameSiteCookieMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\SecurityMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php', + 'OC\\AppFramework\\Middleware\\Security\\TokenScopeMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/Security/TokenScopeMiddleware.php', 'OC\\AppFramework\\Middleware\\SessionMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/SessionMiddleware.php', 'OC\\AppFramework\\OCS\\BaseResponse' => $baseDir . '/lib/private/AppFramework/OCS/BaseResponse.php', 'OC\\AppFramework\\OCS\\V1Response' => $baseDir . '/lib/private/AppFramework/OCS/V1Response.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index fb689a3d7cad6..d830c2f16413d 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -48,6 +48,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 public static $classMap = array ( 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + 'NCU\\AppFramework\\Http\\Attribute\\TokenScope' => __DIR__ . '/../../..' . '/lib/unstable/AppFramework/Http/Attribute/TokenScope.php', 'NCU\\Config\\Exceptions\\IncorrectTypeException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/IncorrectTypeException.php', 'NCU\\Config\\Exceptions\\TypeConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/TypeConflictException.php', 'NCU\\Config\\Exceptions\\UnknownKeyException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', @@ -1262,6 +1263,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\AppFramework\\DependencyInjection\\DIContainer' => __DIR__ . '/../../..' . '/lib/private/AppFramework/DependencyInjection/DIContainer.php', 'OC\\AppFramework\\Http' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http.php', 'OC\\AppFramework\\Http\\Attributes\\FederationRateLimit' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Attributes/FederationRateLimit.php', + 'OC\\AppFramework\\Http\\Attributes\\NoTokenScopeRequired' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Attributes/NoTokenScopeRequired.php', 'OC\\AppFramework\\Http\\Attributes\\TwoFactorSetUpDoneRequired' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Attributes/TwoFactorSetUpDoneRequired.php', 'OC\\AppFramework\\Http\\Dispatcher' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Dispatcher.php', 'OC\\AppFramework\\Http\\Output' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Output.php', @@ -1289,11 +1291,13 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\NotLoggedInException' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php', 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\SecurityException' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php', 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\StrictCookieMissingException' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php', + 'OC\\AppFramework\\Middleware\\Security\\Exceptions\\TokenScopeException' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/Exceptions/TokenScopeException.php', 'OC\\AppFramework\\Middleware\\Security\\FeaturePolicyMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\PasswordConfirmationMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\RateLimitingMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\SameSiteCookieMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php', 'OC\\AppFramework\\Middleware\\Security\\SecurityMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php', + 'OC\\AppFramework\\Middleware\\Security\\TokenScopeMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/Security/TokenScopeMiddleware.php', 'OC\\AppFramework\\Middleware\\SessionMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/SessionMiddleware.php', 'OC\\AppFramework\\OCS\\BaseResponse' => __DIR__ . '/../../..' . '/lib/private/AppFramework/OCS/BaseResponse.php', 'OC\\AppFramework\\OCS\\V1Response' => __DIR__ . '/../../..' . '/lib/private/AppFramework/OCS/V1Response.php', diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index 491b1c1332f9e..9afbc007bb10d 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -29,6 +29,7 @@ use OC\AppFramework\Middleware\Security\RateLimitingMiddleware; use OC\AppFramework\Middleware\Security\SameSiteCookieMiddleware; use OC\AppFramework\Middleware\Security\SecurityMiddleware; +use OC\AppFramework\Middleware\Security\TokenScopeMiddleware; use OC\AppFramework\Middleware\SessionMiddleware; use OC\AppFramework\ScopedPsrLogger; use OC\AppFramework\Services\AppConfig; @@ -214,6 +215,8 @@ public function __construct( $dispatcher->registerMiddleware($c->get(RateLimitingMiddleware::class)); $dispatcher->registerMiddleware($c->get(PublicShareMiddleware::class)); $dispatcher->registerMiddleware($c->get(AdditionalScriptsMiddleware::class)); + // After AdditionalScriptsMiddleware, so pages are refused before their listeners run + $dispatcher->registerMiddleware($c->get(TokenScopeMiddleware::class)); $coordinator = $c->get(Coordinator::class); $registrationContext = $coordinator->getRegistrationContext(); diff --git a/lib/private/AppFramework/Http/Attributes/NoTokenScopeRequired.php b/lib/private/AppFramework/Http/Attributes/NoTokenScopeRequired.php new file mode 100644 index 0000000000000..ded8fd25aef4f --- /dev/null +++ b/lib/private/AppFramework/Http/Attributes/NoTokenScopeRequired.php @@ -0,0 +1,20 @@ +lockdownManager->isScoped()) { + return; + } + + // Admin-only routes stay closed to app passwords with scopes, even for admins + if (!$this->reflector->hasAnnotationOrAttribute('PublicPage', PublicPage::class) + && !$this->reflector->hasAnnotationOrAttribute('NoAdminRequired', NoAdminRequired::class) + && !$this->reflector->hasAnnotationOrAttribute('SubAdminRequired', SubAdminRequired::class)) { + throw new TokenScopeException(); + } + + $attribute = $this->tokenScope($controller); + if ($attribute instanceof NoTokenScopeRequired && $this->isShipped()) { + return; + } + // The route must name a scope its app is allowed to require, and the token must hold it + if (!$attribute instanceof TokenScope + || !$this->mayRequire($attribute->scope) + || !$this->lockdownManager->hasScope($attribute->scope)) { + throw new TokenScopeException(); + } + } + + /** + * Apps that aren't shipped may only require scopes named after themselves, + * so they can't open their routes to app passwords limited to another app + */ + private function mayRequire(string $scope): bool { + return $this->isShipped() || str_starts_with($scope, $this->appName . ':'); + } + + private function isShipped(): bool { + return $this->appName === 'core' || $this->appManager->isShipped($this->appName); + } + + /** + * A method's attribute replaces its class's, it doesn't add to it + */ + private function tokenScope(Controller $controller): TokenScope|NoTokenScopeRequired|null { + $attribute = $this->reflector->getAttribute(TokenScope::class) + ?? $this->reflector->getAttribute(NoTokenScopeRequired::class) + ?? (new ReflectionClass($controller))->getAttributes(TokenScope::class)[0] + ?? null; + return $attribute?->newInstance(); + } + + /** + * Page renders run every app's listeners and initial state, so none reach a token with scopes + */ + #[\Override] + public function afterController(Controller $controller, string $methodName, Response $response): Response { + if ($response instanceof TemplateResponse && $this->lockdownManager->isScoped()) { + return new Response(Http::STATUS_FORBIDDEN); + } + return $response; + } +} diff --git a/lib/private/Lockdown/LockdownManager.php b/lib/private/Lockdown/LockdownManager.php index ff4320f951f32..3ee8d121ff46f 100644 --- a/lib/private/Lockdown/LockdownManager.php +++ b/lib/private/Lockdown/LockdownManager.php @@ -76,6 +76,13 @@ public function canAccessFilesystem() { return !$scope || ($scope[IToken::SCOPE_FILESYSTEM] ?? false); } + /** + * Malformed scopes count as scoped, so they fail closed + */ + public function isScoped(): bool { + return $this->scopes() !== null; + } + /** * True for every scope when there is no token or it has no scopes (full access) */ diff --git a/lib/unstable/AppFramework/Http/Attribute/TokenScope.php b/lib/unstable/AppFramework/Http/Attribute/TokenScope.php new file mode 100644 index 0000000000000..4a70c56548f4c --- /dev/null +++ b/lib/unstable/AppFramework/Http/Attribute/TokenScope.php @@ -0,0 +1,35 @@ +::`. + * Other scopes are refused on apps that aren't shipped with Nextcloud. + * + * @experimental 36.0.0 + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +final class TokenScope { + /** + * @param string $scope the scope the app password needs + * @experimental 36.0.0 + */ + public function __construct( + public readonly string $scope, + ) { + } +} diff --git a/tests/lib/AppFramework/DependencyInjection/DIContainerTest.php b/tests/lib/AppFramework/DependencyInjection/DIContainerTest.php index 8a9e2729b944d..f9d81d264be45 100644 --- a/tests/lib/AppFramework/DependencyInjection/DIContainerTest.php +++ b/tests/lib/AppFramework/DependencyInjection/DIContainerTest.php @@ -15,7 +15,9 @@ use OC\AppFramework\Bootstrap\RegistrationContext; use OC\AppFramework\DependencyInjection\DIContainer; use OC\AppFramework\Http\Request; +use OC\AppFramework\Middleware\AdditionalScriptsMiddleware; use OC\AppFramework\Middleware\Security\SecurityMiddleware; +use OC\AppFramework\Middleware\Security\TokenScopeMiddleware; use OCP\AppFramework\Middleware; use OCP\AppFramework\QueryException; use OCP\IConfig; @@ -72,6 +74,22 @@ public function testMiddlewareDispatcherIncludesSecurityMiddleware(): void { $this->assertTrue($found); } + public function testTokenScopeMiddlewareRefusesPagesBeforeTheirListenersRun(): void { + $this->container->registerService('Request', fn () => new Request( + ['method' => 'GET'], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + )); + $middlewares = $this->container->get('MiddlewareDispatcher')->getMiddlewares(); + $classes = array_map(get_class(...), $middlewares); + + // After-hooks run in reverse, so registering later means checking the page earlier + $this->assertGreaterThan( + array_search(AdditionalScriptsMiddleware::class, $classes, true), + array_search(TokenScopeMiddleware::class, $classes, true), + ); + } + public function testMiddlewareDispatcherIncludesBootstrapMiddlewares(): void { $coordinator = $this->createMock(Coordinator::class); $this->container->registerService(Coordinator::class, fn () => $coordinator); diff --git a/tests/lib/AppFramework/Middleware/Security/TokenScopeMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/TokenScopeMiddlewareTest.php new file mode 100644 index 0000000000000..86497a4e1740c --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/TokenScopeMiddlewareTest.php @@ -0,0 +1,233 @@ +request = $this->createMock(IRequest::class); + $this->reflector = new ControllerMethodReflector(Server::get(LoggerInterface::class)); + } + + public static function requests(): array { + return [ + 'any token' => [[], TokenScopeTestController::class, 'anyToken', true], + 'scope missing' => [self::FILES, TokenScopeTestController::class, 'share', false], + 'scope held' => [[...self::FILES, SharingTokenScopes::SHARES_WRITE], TokenScopeTestController::class, 'share', true], + 'unannotated' => [self::FILES, TokenScopeTestController::class, 'unannotated', false], + 'unannotated, legacy token' => [null, TokenScopeTestController::class, 'unannotated', true], + 'admin only' => [self::FILES, TokenScopeTestController::class, 'adminOnly', false], + 'class attribute' => [self::FILES, TokenScopeTestClassController::class, 'files', true], + 'class attribute, other scope' => [self::CALENDAR, TokenScopeTestClassController::class, 'files', false], + 'method overrides class' => [self::FILES, TokenScopeTestClassController::class, 'share', false], + 'method opens what the class limits' => [self::CALENDAR, TokenScopeTestClassController::class, 'anyToken', true], + 'app requires its own scope' => [['deck:boards:read'], TokenScopeTestController::class, 'deck', true, 'deck'], + 'app requires another app\'s scope' => [[...self::FILES, SharingTokenScopes::SHARES_WRITE], TokenScopeTestController::class, 'share', false, 'deck'], + 'app opens itself to every token' => [[], TokenScopeTestController::class, 'anyToken', false, 'deck'], + ]; + } + + #[DataProvider('requests')] + public function testBeforeController(?array $scopes, string $class, string $method, bool $allowed, ?string $thirdPartyApp = null): void { + $controller = new $class('test', $this->request); + $this->reflector->reflect($controller, $method); + + if (!$allowed) { + $this->expectException(TokenScopeException::class); + } + $this->middleware($scopes, $thirdPartyApp)->beforeController($controller, $method); + $this->addToAssertionCount(1); + } + + public function testPageRenderIsRefused(): void { + $response = $this->middleware(self::FILES) + ->afterController(new TokenScopeTestController('test', $this->request), 'anyToken', new TemplateResponse('files', 'index')); + + $this->assertNotInstanceOf(TemplateResponse::class, $response); + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testOtherResponsesPass(): void { + $controller = new TokenScopeTestController('test', $this->request); + $json = new JSONResponse([]); + $this->assertSame($json, $this->middleware(self::FILES)->afterController($controller, 'anyToken', $json)); + + $page = new TemplateResponse('files', 'index'); + $this->assertSame($page, $this->middleware(null)->afterController($controller, 'anyToken', $page)); + } + + /** + * Without their own attribute these would fall back to their class's wider scope + */ + public static function pinnedScopes(): array { + return [ + 'create share' => [ShareAPIController::class, 'createShare', SharingTokenScopes::SHARES_WRITE], + 'update share' => [ShareAPIController::class, 'updateShare', SharingTokenScopes::SHARES_WRITE], + 'delete share' => [ShareAPIController::class, 'deleteShare', SharingTokenScopes::SHARES_WRITE], + 'send share email' => [ShareAPIController::class, 'sendShareEmail', SharingTokenScopes::SHARES_WRITE], + 'sharee search' => [ShareesAPIController::class, 'search', SharingTokenScopes::SHARES_WRITE], + 'recommended sharees' => [ShareesAPIController::class, 'findRecommended', SharingTokenScopes::SHARES_WRITE], + 'direct link' => [DirectController::class, 'getUrl', SharingTokenScopes::SHARES_WRITE], + 'preview' => [PreviewController::class, 'getPreview', FilesTokenScopes::FILES_READ], + 'preview by file id' => [PreviewController::class, 'getPreviewByFileId', FilesTokenScopes::FILES_READ], + 'public share download' => [ShareController::class, 'downloadShare', FilesTokenScopes::FILES_READ], + ]; + } + + #[DataProvider('pinnedScopes')] + public function testPinnedScopes(string $class, string $method, string $scope): void { + $attribute = (new \ReflectionMethod($class, $method))->getAttributes(TokenScope::class)[0] + ?? (new \ReflectionClass($class))->getAttributes(TokenScope::class)[0]; + $this->assertSame($scope, $attribute->newInstance()->scope); + } + + /** + * Page renders are refused anyway, so they must not run for a scoped token first + */ + public static function pages(): array { + return [ + [ViewController::class, 'index'], + [ViewController::class, 'indexView'], + [ViewController::class, 'indexViewFileid'], + [ViewController::class, 'showFile'], + [ShareController::class, 'showShare'], + [AcceptController::class, 'showAccept'], + ]; + } + + #[DataProvider('pages')] + public function testPagesHaveNoScope(string $class, string $method): void { + $this->assertSame([], (new \ReflectionMethod($class, $method))->getAttributes(TokenScope::class)); + $this->assertSame([], (new \ReflectionClass($class))->getAttributes(TokenScope::class)); + } + + /** + * These hand files to other apps, so no scoped token may reach them + */ + public static function refusedControllers(): array { + return [ + [TemplateController::class], + [DirectEditingController::class], + [DirectEditingViewController::class], + [ConversionApiController::class], + ]; + } + + #[DataProvider('refusedControllers')] + public function testRefusedControllersStayUnannotated(string $class): void { + $class = new \ReflectionClass($class); + foreach ([TokenScope::class, NoTokenScopeRequired::class] as $attribute) { + $this->assertSame([], $class->getAttributes($attribute)); + foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $this->assertSame([], $method->getAttributes($attribute), $method->getName()); + } + } + } + + /** + * @param string|null $thirdPartyApp app the controller belongs to, null for a shipped app + */ + private function middleware(?array $scopes, ?string $thirdPartyApp = null): TokenScopeMiddleware { + $lockdownManager = new LockdownManager(fn () => $this->createMock(ISession::class), new TokenScopes()); + $token = new PublicKeyToken(); + $token->setScope($scopes === null ? [] : [TokenScopes::KEY => $scopes]); + $lockdownManager->setToken($token); + + $appManager = $this->createMock(IAppManager::class); + $appManager->method('isShipped')->willReturn($thirdPartyApp === null); + + return new TokenScopeMiddleware($this->reflector, $lockdownManager, $appManager, $thirdPartyApp ?? 'files'); + } +} diff --git a/tests/lib/Lockdown/LockdownManagerTest.php b/tests/lib/Lockdown/LockdownManagerTest.php index f3f5dd2a3c07e..c27c2ca6e2850 100644 --- a/tests/lib/Lockdown/LockdownManagerTest.php +++ b/tests/lib/Lockdown/LockdownManagerTest.php @@ -82,6 +82,25 @@ public function testHasScope(array $scope, string $requested, bool $expected): v $this->assertSame($expected, $this->managerWithScope($scope)->hasScope($requested)); } + public function testIsScopedWithoutToken(): void { + $manager = new LockdownManager($this->sessionCallback, new TokenScopes()); + $this->assertFalse($manager->isScoped()); + } + + public static function isScoped(): array { + return [ + 'legacy' => [[IToken::SCOPE_FILESYSTEM => true], false], + 'scoped' => [[TokenScopes::KEY => ['dav:calendar:read']], true], + 'empty scopes' => [[TokenScopes::KEY => []], true], + 'malformed' => [[TokenScopes::KEY => 'dav:calendar:read'], true], + ]; + } + + #[DataProvider('isScoped')] + public function testIsScoped(array $scope, bool $expected): void { + $this->assertSame($expected, $this->managerWithScope($scope)->isScoped()); + } + private function managerWithScope(array $scope): LockdownManager { $token = new PublicKeyToken(); $token->setScope($scope);