config = $config; $this->userSession = $userSession; $this->request = $request; $this->trustedServers = $trustedServers; $this->groupManager = $groupManager; $this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Accounts'); $this->addressBookInfo['{' . Plugin::NS_CARDDAV . '}addressbook-description'] = $l10n->t('System address book which holds all accounts'); } /** * No checkbox checked -> Show only the same user * 'Allow username autocompletion in share dialog' -> show everyone * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' -> show only users in intersecting groups * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users based on phone number integration' -> show only the same user * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' + 'Allow username autocompletion to users based on phone number integration' -> show only users in intersecting groups */ public function getChildren() { $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; $user = $this->userSession->getUser(); if (!$user) { // Should never happen because we don't allow anonymous access return []; } if ($user->getBackendClassName() === 'Guests' || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { $name = SyncService::getCardUri($user); try { return [parent::getChild($name)]; } catch (NotFound $e) { return []; } } if ($shareEnumerationGroup) { if ($this->groupManager === null) { // Group manager is not available, so we can't determine which data is safe return []; } $groups = $this->groupManager->getUserGroups($user); $names = []; foreach ($groups as $group) { $users = $group->getUsers(); foreach ($users as $groupUser) { if ($groupUser->getBackendClassName() === 'Guests') { continue; } $names[] = SyncService::getCardUri($groupUser); } } return parent::getMultipleChildren(array_unique($names)); } $children = parent::getChildren(); return array_filter($children, function (Card $child) { // check only for URIs that begin with Guests: return !str_starts_with($child->getName(), 'Guests:'); }); } /** * @param array $paths * @return Card[] * @throws NotFound */ public function getMultipleChildren($paths): array { $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; $user = $this->userSession->getUser(); if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { // No user or cards with no access if ($user === null || !in_array(SyncService::getCardUri($user), $paths, true)) { return []; } // Only return the own card try { return [parent::getChild(SyncService::getCardUri($user))]; } catch (NotFound $e) { return []; } } if ($shareEnumerationGroup) { if ($this->groupManager === null || $user === null) { // Group manager or user is not available, so we can't determine which data is safe return []; } $groups = $this->groupManager->getUserGroups($user); $allowedNames = []; foreach ($groups as $group) { $users = $group->getUsers(); foreach ($users as $groupUser) { if ($groupUser->getBackendClassName() === 'Guests') { continue; } $allowedNames[] = SyncService::getCardUri($groupUser); } } return parent::getMultipleChildren(array_intersect($paths, $allowedNames)); } if (!$this->isFederation()) { return parent::getMultipleChildren($paths); } $objs = $this->carddavBackend->getMultipleCards($this->addressBookInfo['id'], $paths); $children = []; /** @var array $obj */ foreach ($objs as $obj) { if (empty($obj)) { continue; } $carddata = $this->extractCarddata($obj); if (empty($carddata)) { continue; } else { $obj['carddata'] = $carddata; } $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); } return $children; } /** * @param string $name * @return Card * @throws NotFound * @throws Forbidden */ public function getChild($name): Card { $user = $this->userSession->getUser(); $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { $ownName = $user !== null ? SyncService::getCardUri($user) : null; if ($ownName === $name) { return parent::getChild($name); } throw new Forbidden(); } if ($shareEnumerationGroup) { if ($user === null || $this->groupManager === null) { // Group manager is not available, so we can't determine which data is safe throw new Forbidden(); } $groups = $this->groupManager->getUserGroups($user); foreach ($groups as $group) { foreach ($group->getUsers() as $groupUser) { if ($groupUser->getBackendClassName() === 'Guests') { continue; } $otherName = SyncService::getCardUri($groupUser); if ($otherName === $name) { return parent::getChild($name); } } } throw new Forbidden(); } if (!$this->isFederation()) { return parent::getChild($name); } $obj = $this->carddavBackend->getCard($this->addressBookInfo['id'], $name); if (!$obj) { throw new NotFound('Card not found'); } $carddata = $this->extractCarddata($obj); if (empty($carddata)) { throw new Forbidden(); } else { $obj['carddata'] = $carddata; } return new Card($this->carddavBackend, $this->addressBookInfo, $obj); } /** * @throws UnsupportedLimitOnInitialSyncException */ public function getChanges($syncToken, $syncLevel, $limit = null) { if (!$syncToken && $limit) { throw new UnsupportedLimitOnInitialSyncException(); } if (!$this->carddavBackend instanceof SyncSupport) { return null; } if (!$this->isFederation()) { return parent::getChanges($syncToken, $syncLevel, $limit); } $changed = $this->carddavBackend->getChangesForAddressBook( $this->addressBookInfo['id'], $syncToken, $syncLevel, $limit ); if (empty($changed)) { return $changed; } $added = $modified = $deleted = []; foreach ($changed['added'] as $uri) { try { $this->getChild($uri); $added[] = $uri; } catch (NotFound|Forbidden $e) { $deleted[] = $uri; } } foreach ($changed['modified'] as $uri) { try { $this->getChild($uri); $modified[] = $uri; } catch (NotFound|Forbidden $e) { $deleted[] = $uri; } } $changed['added'] = $added; $changed['modified'] = $modified; $changed['deleted'] = $deleted; return $changed; } private function isFederation(): bool { if ($this->trustedServers === null || $this->request === null) { return false; } /** @psalm-suppress NoInterfaceProperties */ $server = $this->request->server; if (!isset($server['PHP_AUTH_USER']) || $server['PHP_AUTH_USER'] !== 'system') { return false; } /** @psalm-suppress NoInterfaceProperties */ $sharedSecret = $server['PHP_AUTH_PW'] ?? null; if ($sharedSecret === null) { return false; } $servers = $this->trustedServers->getServers(); $trusted = array_filter($servers, function ($trustedServer) use ($sharedSecret) { return $trustedServer['shared_secret'] === $sharedSecret; }); // Authentication is fine, but it's not for a federated share if (empty($trusted)) { return false; } return true; } /** * If the validation doesn't work the card is "not found" so we * return empty carddata even if the carddata might exist in the local backend. * This can happen when a user sets the required properties * FN, N to a local scope only but the request is from * a federated share. * * @see https://github.com/nextcloud/server/issues/38042 * * @param array $obj * @return string|null */ private function extractCarddata(array $obj): ?string { $obj['acl'] = $this->getChildACL(); $cardData = $obj['carddata']; /** @var VCard $vCard */ $vCard = Reader::read($cardData); foreach ($vCard->children() as $child) { $scope = $child->offsetGet('X-NC-SCOPE'); if ($scope !== null && $scope->getValue() === IAccountManager::SCOPE_LOCAL) { $vCard->remove($child); } } $messages = $vCard->validate(); if (!empty($messages)) { return null; } return $vCard->serialize(); } /** * @return mixed * @throws Forbidden */ public function delete() { if ($this->isFederation()) { parent::delete(); } throw new Forbidden(); } public function getACL() { return array_filter(parent::getACL(), function ($acl) { if (in_array($acl['privilege'], ['{DAV:}write', '{DAV:}all'], true)) { return false; } return true; }); } }