CVE-2026-45286
Description
Nextcloud Calendar app allows authenticated users to enumerate other users via attendee suggestions, bypassing sharing restrictions.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Nextcloud Calendar app allows authenticated users to enumerate other users via attendee suggestions, bypassing sharing restrictions.
Vulnerability
An authenticated user can enumerate users on a Nextcloud instance by using the Calendar app's endpoint for suggesting attendees. This vulnerability affects versions 5.5.13 to before 5.5.17 and 6.2.0 to before 6.2.3. The sharing restrictions that are applied to other endpoints were not effective for this specific endpoint [3].
Exploitation
An attacker who has authenticated to the Nextcloud instance can exploit this vulnerability by typing any letter into the attendees dropdown menu within the Calendar app. This action triggers an autocompletion feature that reveals all users on the instance matching the typed letter, regardless of any privacy settings [2].
Impact
Successful exploitation allows an authenticated attacker to enumerate all users on the Nextcloud instance, including their email addresses. This constitutes a data protection issue, as it bypasses intended sharing restrictions and reveals user identifiers to unauthorized individuals [2, 3].
Mitigation
This vulnerability has been patched in Nextcloud Calendar app versions 5.5.17 and 6.2.3 [3]. Users are recommended to upgrade to these fixed versions. As a workaround, the Calendar app can be disabled if an immediate upgrade is not possible [3].
AI Insight generated on Jun 1, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1b9f53023dc50Merge pull request #8197 from nextcloud/fix/improve-attendee-search
2 files changed · +297 −103
lib/Controller/ContactController.php+97 −2 modified@@ -19,6 +19,8 @@ use OCP\AppFramework\QueryException; use OCP\Contacts\IManager; use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserManager; use Psr\Log\LoggerInterface; @@ -43,7 +45,10 @@ public function __construct( private IUserManager $userManager, private ContactsService $contactsService, private IAppConfig $appConfig, + private IConfig $config, + private IGroupManager $groupManager, private LoggerInterface $logger, + private ?string $userId, ) { parent::__construct($appName, $request); } @@ -102,23 +107,113 @@ public function searchAttendee(string $search):JSONResponse { return new JSONResponse(); } + // Read restriction configuration $externalAttendeesDisabled = $this->appConfig->getValueBool('dav', 'caldav_external_attendees_disabled', false); - $result = $this->contactsManager->search($search, ['FN', 'EMAIL'], ['enumeration' => true]); + $shareeGroupsOnlyRestriction = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes'; + $shareeGroupsOnlyExclusion = []; + $shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'no') === 'yes'; + $shareeEnumerationInGroupOnly = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $shareeEnumerationFullMatch = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes'; + $shareeEnumerationFullMatchUserId = $shareeEnumerationFullMatch && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match_userid', 'yes') === 'yes'; + $shareeEnumerationFullMatchEmail = $shareeEnumerationFullMatch && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes') === 'yes'; + if ($shareeGroupsOnlyRestriction) { + $excludedGroups = json_decode( + $this->config->getAppValue('core', 'shareapi_only_share_with_group_members_exclude_group_list', '[]'), + true + ); + if (is_array($excludedGroups)) { + $shareeGroupsOnlyExclusion = $excludedGroups; + } + } + + $result = $this->contactsManager->search( + $search, + ['UID', 'FN', 'EMAIL'], + [ + 'enumeration' => $shareeEnumeration, + 'fullmatch' => $shareeEnumerationFullMatch, + ] + ); + + // Get user groups if group restriction is enabled + $userGroups = []; + if (($shareeGroupsOnlyRestriction || ($shareeEnumeration && $shareeEnumerationInGroupOnly)) && $this->userId !== null) { + $user = $this->userManager->get($this->userId); + if ($user === null) { + return new JSONResponse(); + } + $userGroups = $this->groupManager->getUserGroupIds($user); + + if ($shareeGroupsOnlyRestriction && $shareeGroupsOnlyExclusion !== [] + && array_intersect($userGroups, $shareeGroupsOnlyExclusion) !== []) { + $shareeGroupsOnlyRestriction = false; + } + } $contacts = []; foreach ($result as $r) { if (!$this->contactsService->hasEmail($r)) { continue; } + + $isSystemUser = $this->contactsService->isSystemBook($r); + // When external attendees are disabled, only include system book contacts - if ($externalAttendeesDisabled && !$this->contactsService->isSystemBook($r)) { + if ($externalAttendeesDisabled && !$isSystemUser) { continue; } + + // Apply group restrictions for system users + $isInSameGroup = false; + if ($isSystemUser && ($shareeGroupsOnlyRestriction || $shareeEnumerationInGroupOnly)) { + foreach ($userGroups as $userGroup) { + if ($this->groupManager->isInGroup($r['UID'], $userGroup)) { + $isInSameGroup = true; + break; + } + } + if ($shareeGroupsOnlyRestriction && !$isInSameGroup) { + continue; + } + if (!$shareeEnumerationFullMatch && !$isInSameGroup) { + continue; + } + } + $name = $this->contactsService->getNameFromContact($r); $email = $this->contactsService->getEmail($r); $photo = $this->contactsService->getPhotoUri($r); $timezoneId = $this->contactsService->getTimezoneId($r); $lang = $this->contactsService->getLanguageId($r); + + // Check full match requirements for system users not in same group + if ($isSystemUser && $shareeEnumerationInGroupOnly && !$isInSameGroup) { + $lowerSearch = strtolower($search); + $matchFound = false; + + // Check for full match on name, user ID, or email + if ($lowerSearch !== '') { + if ($shareeEnumerationFullMatch && !empty($name) && $lowerSearch === strtolower($name)) { + $matchFound = true; + } + if ($shareeEnumerationFullMatchUserId && $lowerSearch === strtolower($r['UID'])) { + $matchFound = true; + } + if ($shareeEnumerationFullMatchEmail && $email) { + foreach ($email as $e) { + if ($lowerSearch === strtolower($e)) { + $matchFound = true; + break; + } + } + } + } + + if (!$matchFound) { + continue; + } + } + $contacts[] = [ 'name' => $name, 'emails' => $email,
tests/php/unit/Controller/ContactControllerTest.php+200 −101 modified@@ -13,7 +13,10 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\Contacts\IManager; use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IGroupManager; use OCP\IRequest; +use OCP\IUser; use OCP\IUserManager; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -36,6 +39,8 @@ class ContactControllerTest extends TestCase { private $userManager; private ContactsService|MockObject $service; private IAppConfig|MockObject $appConfig; + private IConfig|MockObject $config; + private IGroupManager|MockObject $groupManager; /** @var ContactController */ protected $controller; @@ -52,6 +57,8 @@ protected function setUp():void { $this->userManager = $this->createMock(IUserManager::class); $this->service = $this->createMock(ContactsService::class); $this->appConfig = $this->createMock(IAppConfig::class); + $this->config = $this->createMock(IConfig::class); + $this->groupManager = $this->createMock(IGroupManager::class); $this->logger = $this->createMock(NullLogger::class); $this->controller = new ContactController($this->appName, $this->request, @@ -60,7 +67,10 @@ protected function setUp():void { $this->userManager, $this->service, $this->appConfig, + $this->config, + $this->groupManager, $this->logger, + 'test-user', ); } @@ -297,152 +307,94 @@ public function testSearchAttendeeDisabled():void { public function testSearchAttendee():void { $user1 = [ 'FN' => 'Person 1', - 'ADR' => [ - '33 42nd Street;Random Town;Some State;;United States', - ';;5 Random Ave;12782 Some big city;Yet another state;United States', - ], - 'EMAIL' => [ - 'foo1@example.com', - 'foo2@example.com', - ], - 'LANG' => [ - 'de', - 'en' - ], - 'TZ' => [ - 'Europe/Berlin', - 'UTC' - ], + 'EMAIL' => ['foo1@example.com', 'foo2@example.com'], + 'LANG' => 'de', + 'TZ' => 'Europe/Berlin', 'PHOTO' => 'VALUE=uri:http://foo.bar' ]; - $user2 = [ - 'FN' => 'Person 2', - 'EMAIL' => 'foo3@example.com', - ]; - $user3 = [ - 'ADR' => [ - 'ABC Street 2;01337 Village;;Germany', - ], - 'LANG' => 'en_us', - 'TZ' => 'Australia/Adelaide', - 'PHOTO' => 'VALUE:BINARY:4242424242' - ]; - $user4 = [ + $systemUser = [ + 'UID' => 'system-user', 'isLocalSystemBook' => true, - 'FN' => 'Person 3', - 'ADR' => [ - 'ABC Street 2;01337 Village;;Germany', - ], - 'LANG' => 'en_us', - 'TZ' => 'Australia/Adelaide', - 'PHOTO' => 'VALUE:BINARY:4242424242', - 'CATEGORIES' => 'search 123' + 'FN' => 'System User', + 'EMAIL' => ['system@example.com'], + 'LANG' => 'en', + 'TZ' => 'UTC', ]; $this->manager->expects(self::once()) ->method('isEnabled') ->willReturn(true); + $this->appConfig->expects(self::once()) ->method('getValueBool') ->with('dav', 'caldav_external_attendees_disabled', false) ->willReturn(false); - $this->service - ->method('hasEmail') + + // Enumeration enabled, no group restrictions + $this->config->expects(self::exactly(6)) + ->method('getAppValue') ->willReturnMap([ - [$user1, true], - [$user2, true], - [$user3, false], - [$user4, true], + ['core', 'shareapi_only_share_with_group_members', 'no', 'no'], + ['core', 'shareapi_allow_share_dialog_user_enumeration', 'no', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_to_group', 'no', 'no'], + ['core', 'shareapi_restrict_user_enumeration_full_match', 'yes', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_full_match_userid', 'yes', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes', 'yes'], ]); + + $this->service + ->method('hasEmail') + ->willReturn(true); $this->service ->method('isSystemBook') ->willReturnMap([ [$user1, false], - [$user2, false], - [$user3, false], - [$user4, true], + [$systemUser, true], ]); $this->service ->method('getNameFromContact') ->willReturnMap([ [$user1, 'Person 1'], - [$user2, 'Person 2'], - [$user3, ''], - [$user4, 'Person 3'], + [$systemUser, 'System User'], ]); - $this->service->expects(self::exactly(3)) + $this->service ->method('getLanguageId') ->willReturnMap([ [$user1, 'de'], - [$user2, null], - [$user4, 'en_us'], + [$systemUser, 'en'], ]); - $this->service->expects(self::exactly(3)) + $this->service ->method('getTimezoneId') ->willReturnMap([ [$user1, 'Europe/Berlin'], - [$user2, null], - [$user4, 'Australia/Adelaide'], + [$systemUser, 'UTC'], ]); - $this->service->expects(self::exactly(3)) + $this->service ->method('getEmail') ->willReturnMap([ - [$user1, [ - 'foo1@example.com', - 'foo2@example.com', - ] - ], - [$user2, ['foo3@example.com']], - [$user4, ['foo4@example.com']], + [$user1, ['foo1@example.com', 'foo2@example.com']], + [$systemUser, ['system@example.com']], ]); $this->service->method('getPhotoUri') ->willReturnMap([ [$user1, 'http://foo.bar'], - [$user2, null], - [$user3, null], - [$user4, null], + [$systemUser, null], ]); + $this->manager->expects(self::exactly(2)) ->method('search') ->willReturnMap([ - ['search 123', ['FN', 'EMAIL'], ['enumeration' => true], [$user1, $user2, $user3, $user4]], - ['search 123', ['CATEGORIES'], [], [$user4]] + ['search', ['UID', 'FN', 'EMAIL'], ['enumeration' => true, 'fullmatch' => true], [$user1, $systemUser]], + ['search', ['CATEGORIES'], [], []] ]); - $response = $this->controller->searchAttendee('search 123'); + $response = $this->controller->searchAttendee('search'); $this->assertInstanceOf(JSONResponse::class, $response); - $this->assertEquals([ - [ - 'name' => 'Person 1', - 'emails' => [ - 'foo1@example.com', - 'foo2@example.com', - ], - 'lang' => 'de', - 'tzid' => 'Europe/Berlin', - 'photo' => 'http://foo.bar', - 'type' => 'individual' - ], [ - 'name' => 'Person 2', - 'emails' => [ - 'foo3@example.com' - ], - 'lang' => null, - 'tzid' => null, - 'photo' => null, - 'type' => 'individual' - ], [ - 'name' => 'Person 3', - 'emails' => [ - 'foo4@example.com' - ], - 'lang' => 'en_us', - 'tzid' => 'Australia/Adelaide', - 'photo' => null, - 'type' => 'individual' - ] - ], $response->getData()); + $data = $response->getData(); + $this->assertCount(2, $data); + $this->assertEquals('Person 1', $data[0]['name']); + $this->assertEquals('System User', $data[1]['name']); $this->assertEquals(200, $response->getStatus()); } @@ -462,6 +414,7 @@ public function testSearchAttendeeExternalAttendeesDisabled():void { 'EMAIL' => 'foo3@example.com', ]; $user3 = [ + 'UID' => 'system-user', 'isLocalSystemBook' => true, 'FN' => 'System User', 'EMAIL' => 'system@example.com', @@ -472,10 +425,23 @@ public function testSearchAttendeeExternalAttendeesDisabled():void { $this->manager->expects(self::once()) ->method('isEnabled') ->willReturn(true); + $this->appConfig->expects(self::once()) ->method('getValueBool') ->with('dav', 'caldav_external_attendees_disabled', false) ->willReturn(true); + + $this->config->expects(self::exactly(6)) + ->method('getAppValue') + ->willReturnMap([ + ['core', 'shareapi_only_share_with_group_members', 'no', 'no'], + ['core', 'shareapi_allow_share_dialog_user_enumeration', 'no', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_to_group', 'no', 'no'], + ['core', 'shareapi_restrict_user_enumeration_full_match', 'yes', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_full_match_userid', 'yes', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes', 'yes'], + ]); + $this->service ->method('hasEmail') ->willReturnMap([ @@ -511,9 +477,10 @@ public function testSearchAttendeeExternalAttendeesDisabled():void { ->method('getPhotoUri') ->with($user3) ->willReturn(null); + $this->manager->expects(self::once()) ->method('search') - ->with('search 123', ['FN', 'EMAIL'], ['enumeration' => true]) + ->with('search 123', ['UID', 'FN', 'EMAIL'], ['enumeration' => true, 'fullmatch' => true]) ->willReturn([$user1, $user2, $user3]); $response = $this->controller->searchAttendee('search 123'); @@ -532,6 +499,138 @@ public function testSearchAttendeeExternalAttendeesDisabled():void { $this->assertEquals(200, $response->getStatus()); } + public function testSearchAttendeeShareWithGroupOnlyExcludesConfiguredGroups(): void { + $externalContact = [ + 'FN' => 'External Person', + 'EMAIL' => ['external@example.com'], + 'LANG' => 'de', + 'TZ' => 'Europe/Berlin', + ]; + $allowedSystemUser = [ + 'UID' => 'allowed-system-user', + 'isLocalSystemBook' => true, + 'FN' => 'Allowed System User', + 'EMAIL' => ['allowed@example.com'], + 'LANG' => 'en', + 'TZ' => 'UTC', + ]; + $excludedSystemUser = [ + 'UID' => 'excluded-system-user', + 'isLocalSystemBook' => true, + 'FN' => 'Excluded System User', + 'EMAIL' => ['excluded@example.com'], + 'LANG' => 'en', + 'TZ' => 'UTC', + ]; + $user = $this->createMock(IUser::class); + + $this->manager->expects(self::once()) + ->method('isEnabled') + ->willReturn(true); + + $this->appConfig->expects(self::once()) + ->method('getValueBool') + ->with('dav', 'caldav_external_attendees_disabled', false) + ->willReturn(false); + + $this->config->expects(self::exactly(7)) + ->method('getAppValue') + ->willReturnMap([ + ['core', 'shareapi_only_share_with_group_members', 'no', 'yes'], + ['core', 'shareapi_only_share_with_group_members_exclude_group_list', '[]', '["excluded-group"]'], + ['core', 'shareapi_allow_share_dialog_user_enumeration', 'no', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_to_group', 'no', 'no'], + ['core', 'shareapi_restrict_user_enumeration_full_match', 'yes', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_full_match_userid', 'yes', 'yes'], + ['core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes', 'yes'], + ]); + + $this->userManager->expects(self::once()) + ->method('get') + ->with('test-user') + ->willReturn($user); + + $this->groupManager->expects(self::once()) + ->method('getUserGroupIds') + ->with($user) + ->willReturn(['allowed-group', 'excluded-group']); + + $this->groupManager->expects(self::never()) + ->method('isInGroup') + ; + + $this->service->method('hasEmail')->willReturn(true); + $this->service->method('isSystemBook') + ->willReturnMap([ + [$externalContact, false], + [$allowedSystemUser, true], + [$excludedSystemUser, true], + ]); + $this->service->method('getNameFromContact') + ->willReturnMap([ + [$externalContact, 'External Person'], + [$allowedSystemUser, 'Allowed System User'], + [$excludedSystemUser, 'Excluded System User'], + ]); + $this->service->method('getLanguageId') + ->willReturnMap([ + [$externalContact, 'de'], + [$allowedSystemUser, 'en'], + [$excludedSystemUser, 'en'], + ]); + $this->service->method('getTimezoneId') + ->willReturnMap([ + [$externalContact, 'Europe/Berlin'], + [$allowedSystemUser, 'UTC'], + [$excludedSystemUser, 'UTC'], + ]); + $this->service->method('getEmail') + ->willReturnMap([ + [$externalContact, ['external@example.com']], + [$allowedSystemUser, ['allowed@example.com']], + [$excludedSystemUser, ['excluded@example.com']], + ]); + $this->service->method('getPhotoUri')->willReturn(null); + + $this->manager->expects(self::exactly(2)) + ->method('search') + ->willReturnMap([ + ['search', ['UID', 'FN', 'EMAIL'], ['enumeration' => true, 'fullmatch' => true], [$externalContact, $allowedSystemUser, $excludedSystemUser]], + ['search', ['CATEGORIES'], [], []], + ]); + + $response = $this->controller->searchAttendee('search'); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertEquals([ + [ + 'name' => 'External Person', + 'emails' => ['external@example.com'], + 'lang' => 'de', + 'tzid' => 'Europe/Berlin', + 'photo' => null, + 'type' => 'individual', + ], + [ + 'name' => 'Allowed System User', + 'emails' => ['allowed@example.com'], + 'lang' => 'en', + 'tzid' => 'UTC', + 'photo' => null, + 'type' => 'individual', + ], + [ + 'name' => 'Excluded System User', + 'emails' => ['excluded@example.com'], + 'lang' => 'en', + 'tzid' => 'UTC', + 'photo' => null, + 'type' => 'individual', + ], + ], $response->getData()); + $this->assertEquals(200, $response->getStatus()); + } + public function testSearchPhotoDisabled():void { $this->manager->expects(self::once()) ->method('isEnabled')
Vulnerability mechanics
Root cause
"The Calendar app's attendee suggestion endpoint did not enforce sharing restrictions."
Attack vector
An authenticated user can exploit this vulnerability by interacting with the Calendar app's attendee suggestion feature. By typing any character into the attendees dropdown menu, the user can trigger an autocompletion that reveals all users on the Nextcloud instance, regardless of sharing settings [ref_id=1]. This bypasses intended privacy controls, allowing for user enumeration.
Affected code
The issue lies within the Calendar app's attendee suggestion endpoint, which failed to respect existing sharing restrictions. The advisory notes that this bypasses the Share-API settings, even when the Share-API is deactivated [ref_id=1].
What the fix does
The vulnerability was addressed by ensuring that the sharing restrictions, which are applied to other endpoints, are also enforced for the attendee suggestion endpoint within the Calendar app. This prevents unauthenticated or unauthorized users from enumerating other users on the instance through this specific feature.
Preconditions
- authThe attacker must be an authenticated user on the Nextcloud instance.
Reproduction
1. Open an event in the calendar. 2. Type any letter in the attendees dropdown menu. 3. Autocompletion will show everyone in the nextcloud instance that matches the letter, regardless of any settings made considering autocompletion [ref_id=1].
Generated on Jun 1, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.