refactor(ocm): expose confirmRequestOrigin as a function on ocmDiscoveryService

Apps implementing OCM endpoints via OCMEndpointRequestEvent (e.g.
SUNET/nextcloud-ocm_request_share for request-share, nextcloud/contacts
for invite-accepted) need to apply the same identity check that the
built-in addShare and receiveNotification handlers apply, so it makes
sense to make it publicly accessible.

It also allows us to refactor RequestHandlerController::confirmSignedOrigin
to use the new public method and drop the confirmNotificationIdentity helper.

Signed-off-by: Micke Nordin <kano@sunet.se>
This commit is contained in:
Micke Nordin 2026-05-17 18:41:31 +02:00 committed by Micke Nordin
parent 1bad4fe238
commit c753aad9e3
4 changed files with 69 additions and 71 deletions

View file

@ -12,7 +12,6 @@ use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent;
use OCA\CloudFederationAPI\ResponseDefinitions;
use OCA\FederatedFileSharing\AddressHandler;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@ -38,11 +37,8 @@ use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\IIncomingSignedRequest;
use OCP\Security\Signature\ISignatureManager;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Util;
use Psr\Log\LoggerInterface;
@ -69,12 +65,10 @@ class RequestHandlerController extends Controller {
private Config $config,
private IEventDispatcher $dispatcher,
private FederatedInviteMapper $federatedInviteMapper,
private readonly AddressHandler $addressHandler,
private readonly IAppConfig $appConfig,
private ICloudFederationFactory $factory,
private ICloudIdManager $cloudIdManager,
private readonly IOCMDiscoveryService $ocmDiscoveryService,
private readonly ISignatureManager $signatureManager,
private ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
@ -440,6 +434,8 @@ class RequestHandlerController extends Controller {
* 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
@ -447,21 +443,13 @@ class RequestHandlerController extends Controller {
* @throws IncomingRequestException
*/
private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void {
if ($signedRequest === null) {
$instance = $this->getHostFromFederationId($value);
try {
$this->signatureManager->getSignatory($instance);
throw new IncomingRequestException('instance is supposed to sign its request');
} catch (SignatoryNotFoundException) {
return;
}
}
$body = json_decode($signedRequest->getBody(), true) ?? [];
$entry = trim($body[$key] ?? '', '@');
if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) {
throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']');
if ($signedRequest !== null) {
$body = json_decode($signedRequest->getBody(), true) ?? [];
$entry = trim(($body[$key] ?? ''), '@');
} else {
$entry = trim($value, '@');
}
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $entry);
}
/**
@ -498,48 +486,6 @@ class RequestHandlerController extends Controller {
throw new IncomingRequestException($e->getMessage(), previous: $e);
}
$this->confirmNotificationEntry($signedRequest, $identity);
}
/**
* @param IIncomingSignedRequest|null $signedRequest
* @param string $entry
*
* @return void
* @throws IncomingRequestException
*/
private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void {
$instance = $this->getHostFromFederationId($entry);
if ($signedRequest === null) {
try {
$this->signatureManager->getSignatory($instance);
throw new IncomingRequestException('instance is supposed to sign its request');
} catch (SignatoryNotFoundException) {
return;
}
} elseif ($instance !== $signedRequest->getOrigin()) {
throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin());
}
}
/**
* @param string $entry
* @return string
* @throws IncomingRequestException
*/
private function getHostFromFederationId(string $entry): string {
if (!str_contains($entry, '@')) {
throw new IncomingRequestException('entry ' . $entry . ' does not contain @');
}
$rightPart = substr($entry, strrpos($entry, '@') + 1);
// in case the full scheme is sent; getting rid of it
$rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart);
try {
return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart);
} catch (IdentityNotFoundException) {
throw new IncomingRequestException('invalid host within federation id: ' . $entry);
}
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
}
}

View file

@ -13,7 +13,6 @@ use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Controller\RequestHandlerController;
use OCA\CloudFederationAPI\Db\FederatedInvite;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
use OCA\FederatedFileSharing\AddressHandler;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Utility\ITimeFactory;
@ -28,7 +27,6 @@ use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
@ -43,13 +41,11 @@ class RequestHandlerControllerTest extends TestCase {
private Config&MockObject $config;
private IEventDispatcher&MockObject $eventDispatcher;
private FederatedInviteMapper&MockObject $federatedInviteMapper;
private AddressHandler&MockObject $addressHandler;
private IAppConfig&MockObject $appConfig;
private ICloudFederationFactory&MockObject $cloudFederationFactory;
private ICloudIdManager&MockObject $cloudIdManager;
private IOCMDiscoveryService&MockObject $discoveryService;
private ISignatureManager&MockObject $signatureManager;
private ITimeFactory&MockObject $timeFactory;
private RequestHandlerController $requestHandlerController;
@ -66,12 +62,10 @@ class RequestHandlerControllerTest extends TestCase {
$this->config = $this->createMock(Config::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->federatedInviteMapper = $this->createMock(FederatedInviteMapper::class);
$this->addressHandler = $this->createMock(AddressHandler::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->discoveryService = $this->createMock(IOCMDiscoveryService::class);
$this->signatureManager = $this->createMock(ISignatureManager::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->requestHandlerController = new RequestHandlerController(
@ -85,12 +79,10 @@ class RequestHandlerControllerTest extends TestCase {
$this->config,
$this->eventDispatcher,
$this->federatedInviteMapper,
$this->addressHandler,
$this->appConfig,
$this->cloudFederationFactory,
$this->cloudIdManager,
$this->discoveryService,
$this->signatureManager,
$this->timeFactory,
);
}

View file

@ -18,6 +18,7 @@ use OC\OCM\Model\OCMProvider;
use OCP\AppFramework\Attribute\Consumable;
use OCP\AppFramework\Http;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\ICloudIdManager;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
@ -63,6 +64,7 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
private IURLGenerator $urlGenerator,
private readonly ISignatureManager $signatureManager,
private readonly OCMSignatoryManager $signatoryManager,
private readonly ICloudIdManager $cloudIdManager,
private LoggerInterface $logger,
) {
$this->cache = $cacheFactory->createDistributed('ocm-discovery');
@ -277,6 +279,49 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
return null;
}
/**
* @inheritDoc
*
* @since 34.0.0
*/
#[\Override]
public function confirmRequestOrigin(?string $signedOrigin, string $ocmAddress): void {
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
return;
}
$instance = $this->getHostFromOcmAddress($ocmAddress);
if ($signedOrigin === null) {
try {
$this->signatureManager->getSignatory($instance);
} catch (SignatoryNotFoundException) {
return;
}
throw new IncomingRequestException('instance is supposed to sign its request');
}
if ($instance !== $signedOrigin) {
throw new IncomingRequestException(
'claimed origin ' . $instance . ' does not match signed origin ' . $signedOrigin
);
}
}
/**
* @throws IncomingRequestException on malformed address or unresolvable host
*/
private function getHostFromOcmAddress(string $entry): string {
try {
$cloudId = $this->cloudIdManager->resolveCloudId(trim($entry, '@'));
return $this->signatureManager->extractIdentityFromUri($cloudId->getRemote());
} catch (\InvalidArgumentException $e) {
throw new IncomingRequestException('invalid OCM address: ' . $entry, previous: $e);
} catch (IdentityNotFoundException $e) {
throw new IncomingRequestException('invalid host within OCM address: ' . $entry, previous: $e);
}
}
/**
* @inheritDoc
*

View file

@ -65,6 +65,21 @@ interface IOCMDiscoveryService {
*/
public function getIncomingSignedRequest(): ?IIncomingSignedRequest;
/**
* Confirm that the host portion of $ocmAddress matches $signedOrigin
* under the current local signing policy.
*
* @param string|null $signedOrigin verified origin of the signed request,
* typically taken from {@see IIncomingSignedRequest::getOrigin()} or
* from {@see \OCP\OCM\Events\OCMEndpointRequestEvent::getRemote()}.
* NULL if the request was not signed.
* @param string $ocmAddress in `user@host` or `user@https://host` form
*
* @throws IncomingRequestException on mismatch or malformed address
* @since 34.0.0
*/
public function confirmRequestOrigin(?string $signedOrigin, string $ocmAddress): void;
/**
* Request a remote OCM endpoint.
*