nextcloud/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php
Micke Nordin 4d5841761f
fix(cloud_federation_api): accept multi-protocol share envelopes
Shares using the OCM multi-protocol envelope (name multi, with the secret carried in a sibling protocol entry such as webdav) were rejected with Missing sharedSecret in protocol. Scan every protocol entry for the shared secret during validation, resolve the secret from the matching entry, and let the files provider serve the webdav entry of a multi envelope. Covers the file and folder resource types.

Signed-off-by: Micke Nordin <kano@sunet.se>
2026-06-17 11:01:19 +02:00

591 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Controller;
use OC\Authentication\Token\PublicKeyTokenProvider;
use OC\OCM\OCMSignatoryManager;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
use OCA\CloudFederationAPI\Db\OcmTokenMapMapper;
use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent;
use OCA\CloudFederationAPI\ResponseDefinitions;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\Exceptions\ActionNotSupportedException;
use OCP\Federation\Exceptions\AuthenticationFailedException;
use OCP\Federation\Exceptions\BadRequestException;
use OCP\Federation\Exceptions\ProviderCouldNotAddShareException;
use OCP\Federation\Exceptions\ProviderDoesNotExistsException;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudIdManager;
use OCP\Federation\ISignedCloudFederationProvider;
use OCP\Federation\IValidationAwareCloudFederationProvider;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\OCM\Events\OCMNotificationReceivedEvent;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\IIncomingSignedRequest;
use OCP\Server;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Util;
use Psr\Log\LoggerInterface;
/**
* Open-Cloud-Mesh-API
*
* @package OCA\CloudFederationAPI\Controller
*
* @psalm-import-type CloudFederationAPIAddShare from ResponseDefinitions
* @psalm-import-type CloudFederationAPIValidationError from ResponseDefinitions
* @psalm-import-type CloudFederationAPIError from ResponseDefinitions
*/
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
class RequestHandlerController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private LoggerInterface $logger,
private IUserManager $userManager,
private IGroupManager $groupManager,
private IURLGenerator $urlGenerator,
private ICloudFederationProviderManager $cloudFederationProviderManager,
private Config $config,
private IEventDispatcher $dispatcher,
private FederatedInviteMapper $federatedInviteMapper,
private readonly IAppConfig $appConfig,
private ICloudFederationFactory $factory,
private ICloudIdManager $cloudIdManager,
private readonly IOCMDiscoveryService $ocmDiscoveryService,
private ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
}
/**
* Add share
*
* @param string $shareWith The user who the share will be shared with
* @param string $name The resource name (e.g. document.odt)
* @param string|null $description Share description
* @param string $providerId Resource UID on the provider side
* @param string $owner Provider specific UID of the user who owns the resource
* @param string|null $ownerDisplayName Display name of the user who shared the item
* @param string|null $sharedBy Provider specific UID of the user who shared the resource
* @param string|null $sharedByDisplayName Display name of the user who shared the resource
* @param array{name: string, options?: array<string, mixed>, webdav?: array<string, mixed>} $protocol Legacy format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']], new single-protocol format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]], or multi-protocol envelope per OCM spec: ['name' => 'multi', 'webdav' => ['sharedSecret' => '...', ...], 'webapp' => [...]]
* @param string $shareType 'group' or 'user' share
* @param string $resourceType 'file', 'calendar',...
*
* @return JSONResponse<Http::STATUS_CREATED, CloudFederationAPIAddShare, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
*
* 201: The notification was successfully received. The display name of the recipient might be returned in the body
* 400: Bad request due to invalid parameters, e.g. when `shareWith` is not found or required properties are missing
* 501: Share type or the resource type is not supported
*
* @psalm-suppress InvalidReturnType
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress LessSpecificReturnStatement
*/
#[PublicPage]
#[NoCSRFRequired]
#[BruteForceProtection(action: 'receiveFederatedShare')]
public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) {
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
try {
// if request is signed and well signed, no exceptions are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
$this->confirmSignedOrigin($signedRequest, 'owner', $owner);
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming request exception', ['exception' => $e]);
return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
}
}
// check if all required parameters are set
if (
$shareWith === null
|| $name === null
|| $providerId === null
|| $resourceType === null
|| $shareType === null
|| !is_array($protocol)
|| !isset($protocol['name'])
) {
return new JSONResponse(
[
'message' => 'Missing arguments',
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
}
if (!$this->protocolCarriesSharedSecret($protocol)) {
return new JSONResponse(
[
'message' => 'Missing sharedSecret in protocol',
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
}
$supportedShareTypes = $this->config->getSupportedShareTypes($resourceType);
if (!in_array($shareType, $supportedShareTypes)) {
return new JSONResponse(
['message' => 'Share type "' . $shareType . '" not implemented'],
Http::STATUS_NOT_IMPLEMENTED
);
}
$cloudId = $this->cloudIdManager->resolveCloudId($shareWith);
$shareWithCloudId = $shareWith; // preserve full cloud ID for factory capability discovery
$shareWith = $cloudId->getUser();
if ($shareType === 'user') {
$shareWith = $this->mapUid($shareWith);
if (!$this->userManager->userExists($shareWith)) {
$response = new JSONResponse(
[
'message' => 'User "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
$response->throttle();
return $response;
}
}
if ($shareType === 'group') {
if (!$this->groupManager->groupExists($shareWith)) {
$response = new JSONResponse(
[
'message' => 'Group "' . $shareWith . '" does not exists at ' . $this->urlGenerator->getBaseUrl(),
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
$response->throttle();
return $response;
}
}
// if no explicit display name is given, we use the uid as display name
$ownerDisplayName = $ownerDisplayName === null ? $owner : $ownerDisplayName;
$sharedByDisplayName = $sharedByDisplayName === null ? $sharedBy : $sharedByDisplayName;
// sharedBy* parameter is optional, if nothing is set we assume that it is the same user as the owner
if ($sharedBy === null) {
$sharedBy = $owner;
$sharedByDisplayName = $ownerDisplayName;
}
$ownerDomain = str_contains($owner, '@') ? substr(strrchr($owner, '@'), 1) : null;
$sharedByDomain = str_contains($sharedBy, '@') ? substr(strrchr($sharedBy, '@'), 1) : null;
$domainsToCheck = array_unique(array_filter([$ownerDomain, $sharedByDomain]));
if (count($domainsToCheck) !== 0) {
$spoofChecker = new \Spoofchecker();
foreach ($domainsToCheck as $domain) {
// detect suspicious chars (e.g. "pаypаl" spelled with Cyrillic "а" characters)
// see https://www.php.net/manual/en/spoofchecker.issuspicious.php
if ($spoofChecker->isSuspicious($domain)) {
$response = new JSONResponse(
[
'message' => 'Suspicious domain detected on owner or sharedBy field',
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
$response->throttle();
return $response;
}
}
}
try {
$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
// Pass the original cloud ID so the factory can discover capabilities without warning.
// Then reset shareWith to the local username that shareReceived() needs for user lookup.
$share = $this->factory->getCloudFederationShare($shareWithCloudId, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType);
$share->setShareWith($shareWith);
$share->setProtocol($protocol);
if ($provider instanceof IValidationAwareCloudFederationProvider) {
$provider->validateShare($share);
}
$provider->shareReceived($share);
} catch (BadRequestException $e) {
return new JSONResponse($e->getReturnMessage(), Http::STATUS_BAD_REQUEST);
} catch (ProviderDoesNotExistsException $e) {
return new JSONResponse(
['message' => $e->getMessage()],
Http::STATUS_NOT_IMPLEMENTED
);
} catch (ProviderCouldNotAddShareException $e) {
$status = $e->getCode() ?: Http::STATUS_NOT_IMPLEMENTED;
return new JSONResponse(
['message' => $e->getMessage()],
$status
);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse(
[
'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
}
$responseData = ['recipientDisplayName' => ''];
if ($shareType === 'user') {
$user = $this->userManager->get($shareWith);
if ($user) {
$responseData = [
'recipientDisplayName' => $user->getDisplayName(),
'recipientUserId' => $user->getUID(),
];
}
}
return new JSONResponse($responseData, Http::STATUS_CREATED);
}
/**
* Inform the sender that an invitation was accepted to start sharing
*
* Inform about an accepted invitation so the user on the sender provider's side
* can initiate the OCM share creation. To protect the identity of the parties,
* for shares created following an OCM invitation, the user id MAY be hashed,
* and recipients implementing the OCM invitation workflow MAY refuse to process
* shares coming from unknown parties.
* @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
*
* @param string $recipientProvider The address of the recipent's provider
* @param string $token The token used for the invitation
* @param string $userID The userID of the recipient at the recipient's provider
* @param string $email The email address of the recipient
* @param string $name The display name of the recipient
*
* @return JSONResponse<Http::STATUS_OK, array{userID: string, email: string, name: string}, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_CONFLICT, array{message: string, error: true}, array{}>
*
* Note: Not implementing 404 Invitation token does not exist, instead using 400
* 200: Invitation accepted
* 400: Invalid token
* 403: Invitation token does not exist
* 409: User is already known by the OCM provider
*/
#[PublicPage]
#[NoCSRFRequired]
#[BruteForceProtection(action: 'inviteAccepted')]
public function inviteAccepted(string $recipientProvider, string $token, string $userID, string $email, string $name): JSONResponse {
$this->logger->debug('Processing share invitation for ' . $userID . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name);
$updated = $this->timeFactory->getTime();
if ($token === '') {
$response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST);
$response->throttle();
return $response;
}
try {
$invitation = $this->federatedInviteMapper->findByToken($token);
} catch (DoesNotExistException) {
$response = ['message' => 'Invalid or non existing token', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
$response = new JSONResponse($response, $status);
$response->throttle();
return $response;
}
if ($invitation->isAccepted() === true) {
$response = ['message' => 'Invite already accepted', 'error' => true];
$status = Http::STATUS_CONFLICT;
return new JSONResponse($response, $status);
}
if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) {
$response = ['message' => 'Invitation expired', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
return new JSONResponse($response, $status);
}
$localUser = $this->userManager->get($invitation->getUserId());
if ($localUser === null) {
$response = ['message' => 'Invalid or non existing token', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
$response = new JSONResponse($response, $status);
$response->throttle();
return $response;
}
$sharedFromEmail = $localUser->getEMailAddress();
if ($sharedFromEmail === null) {
$response = ['message' => 'Invalid or non existing token', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
$response = new JSONResponse($response, $status);
$response->throttle();
return $response;
}
$sharedFromDisplayName = $localUser->getDisplayName();
$response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
$status = Http::STATUS_OK;
$invitation->setAccepted(true);
$invitation->setRecipientEmail($email);
$invitation->setRecipientName($name);
$invitation->setRecipientProvider($recipientProvider);
$invitation->setRecipientUserId($userID);
$invitation->setAcceptedAt($updated);
$invitation = $this->federatedInviteMapper->update($invitation);
$event = new FederatedInviteAcceptedEvent($invitation);
$this->dispatcher->dispatchTyped($event);
return new JSONResponse($response, $status);
}
/**
* Send a notification about an existing share
*
* @param string $notificationType Notification type, e.g. SHARE_ACCEPTED
* @param string $resourceType calendar, file, contact,...
* @param string|null $providerId ID of the share
* @param array<string, mixed>|null $notification The actual payload of the notification
*
* @return JSONResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
*
* 201: The notification was successfully received
* 400: Bad request due to invalid parameters, e.g. when `type` is invalid or missing
* 403: Getting resource is not allowed
* 501: The resource type is not supported
*/
#[NoCSRFRequired]
#[PublicPage]
#[BruteForceProtection(action: 'receiveFederatedShareNotification')]
public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) {
// check if all required parameters are set
if (
$notificationType === null
|| $resourceType === null
|| $providerId === null
|| !is_array($notification)
) {
return new JSONResponse(
[
'message' => 'Missing arguments',
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
}
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
try {
// if request is signed and well signed, no exception are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
$this->confirmNotificationIdentity($signedRequest, $resourceType, $notification);
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming request exception', ['exception' => $e]);
return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
}
}
try {
$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
$result = $provider->notificationReceived($notificationType, $providerId, $notification);
} catch (ProviderDoesNotExistsException $e) {
return new JSONResponse(
[
'message' => $e->getMessage(),
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
} catch (ShareNotFound $e) {
$response = new JSONResponse(
[
'message' => $e->getMessage(),
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
$response->throttle();
return $response;
} catch (ActionNotSupportedException $e) {
return new JSONResponse(
['message' => $e->getMessage()],
Http::STATUS_NOT_IMPLEMENTED
);
} catch (BadRequestException $e) {
return new JSONResponse($e->getReturnMessage(), Http::STATUS_BAD_REQUEST);
} catch (AuthenticationFailedException $e) {
$response = new JSONResponse(['message' => 'RESOURCE_NOT_FOUND'], Http::STATUS_FORBIDDEN);
$response->throttle();
return $response;
} catch (\Exception $e) {
$this->logger->warning('incoming notification exception', ['exception' => $e]);
return new JSONResponse(
[
'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(),
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
}
try {
$notificationObject = $this->factory->getCloudFederationNotification();
$notificationObject->setMessage($notificationType, $resourceType, $providerId, $notification);
$notificationEvent = new OCMNotificationReceivedEvent($notificationObject);
$this->dispatcher->dispatchTyped($notificationEvent);
} catch (\Exception $e) {
$this->logger->warning('error while dispatching OCM notification received event', ['exception' => $e]);
}
return new JSONResponse($result, Http::STATUS_CREATED);
}
/**
* Check that the protocol envelope carries at least one sharedSecret.
*
* Accepts the legacy single-protocol shape ['name' => '<type>', 'options' => ['sharedSecret' => ...]],
* the new single-protocol shape ['name' => '<type>', '<type>' => ['sharedSecret' => ...]] and the
* multi-protocol envelope from the OCM spec ['name' => 'multi', '<type>' => ['sharedSecret' => ...], ...].
* Because a payload may use 'name' => 'multi' even when it carries a single inner protocol, we do not
* gate on the name value but scan every sibling entry for the first sharedSecret. The full envelope is
* forwarded to the resource provider unchanged; the provider decides which entries it understands.
*
* @param array<string, mixed> $protocol
* @see https://github.com/cs3org/OCM-API/
*/
private function protocolCarriesSharedSecret(array $protocol): bool {
if (
isset($protocol['options'])
&& is_array($protocol['options'])
&& isset($protocol['options']['sharedSecret'])
&& is_string($protocol['options']['sharedSecret'])
) {
return true;
}
foreach ($protocol as $key => $value) {
if ($key === 'name' || $key === 'options' || !is_array($value)) {
continue;
}
if (isset($value['sharedSecret']) && is_string($value['sharedSecret'])) {
return true;
}
}
return false;
}
/**
* map login name to internal LDAP UID if a LDAP backend is in use
*
* @param string $uid
* @return string mixed
*/
private function mapUid($uid) {
// FIXME this should be a method in the user management instead
$this->logger->debug('shareWith before, ' . $uid, ['app' => $this->appName]);
Util::emitHook(
'\OCA\Files_Sharing\API\Server2Server',
'preLoginNameUsedAsUserName',
['uid' => &$uid]
);
$this->logger->debug('shareWith after, ' . $uid, ['app' => $this->appName]);
return $uid;
}
/**
* confirm that the value related to $key entry from the payload is in format userid@hostname
* and compare hostname with the origin of the signed request.
*
* If request is not signed, we still verify that the hostname from the extracted value does,
* actually, not support signed request
*
* Delegates to {@see IOCMDiscoveryService::confirmRequestOrigin()}.
*
* @param IIncomingSignedRequest|null $signedRequest
* @param string $key entry from data available in data
* @param string $value value itself used in case request is not signed
*
* @throws IncomingRequestException
*/
private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void {
if ($signedRequest !== null) {
$body = json_decode($signedRequest->getBody(), true) ?? [];
$entry = trim(($body[$key] ?? ''), '@');
} else {
$entry = trim($value, '@');
}
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $entry);
}
/**
* confirm identity of the remote instance on notification, based on the share token.
*
* If request is not signed, we still verify that the hostname from the extracted value does,
* actually, not support signed request
*
* @param IIncomingSignedRequest|null $signedRequest
* @param string $resourceType
*
* @throws IncomingRequestException
* @throws BadRequestException
*/
private function confirmNotificationIdentity(
?IIncomingSignedRequest $signedRequest,
string $resourceType,
array $notification,
): void {
$sharedSecret = $notification['sharedSecret'] ?? '';
if ($sharedSecret === '') {
throw new BadRequestException(['sharedSecret']);
}
try {
$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
if ($provider instanceof ISignedCloudFederationProvider || $provider instanceof \NCU\Federation\ISignedCloudFederationProvider) {
$identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification);
if ($identity === '') {
$tokenProvider = Server::get(PublicKeyTokenProvider::class);
$accessTokenDb = $tokenProvider->getToken($sharedSecret);
$mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId());
$identity = $provider->getFederationIdFromSharedSecret($mapping->getRefreshToken(), $notification);
}
} else {
$this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
return;
}
} catch (\Exception $e) {
throw new IncomingRequestException($e->getMessage(), previous: $e);
}
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
}
}