From 2df30e298727a5097b6712bf053e9402d4fea328 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Thu, 20 Oct 2022 14:23:58 +0200 Subject: [PATCH] [PLUGIN][ActivityPub] Sign outgoing GET requests on behalf of relevant actor --- plugins/ActivityPub/ActivityPub.php | 10 +- plugins/ActivityPub/Controller/Inbox.php | 72 +++++++++- plugins/ActivityPub/Util/Explorer.php | 158 +++++++++++++++------ plugins/ActivityPub/Util/HTTPSignature.php | 17 ++- 4 files changed, 199 insertions(+), 58 deletions(-) diff --git a/plugins/ActivityPub/ActivityPub.php b/plugins/ActivityPub/ActivityPub.php index faa0728447..698ea79859 100644 --- a/plugins/ActivityPub/ActivityPub.php +++ b/plugins/ActivityPub/ActivityPub.php @@ -296,11 +296,11 @@ class ActivityPub extends Plugin * * @return bool true if imported, false otherwise */ - public static function freeNetworkGrabRemote(string $uri): bool + public static function freeNetworkGrabRemote(string $uri, ?Actor $on_behalf_of = null): bool { if (Common::isValidHttpUrl($uri)) { try { - $object = self::getObjectByUri($uri); + $object = self::getObjectByUri($uri, try_online: true, on_behalf_of: $on_behalf_of); if (!\is_null($object)) { if ($object instanceof Type\AbstractObject) { if (\in_array($object->get('type'), array_keys(Model\Actor::$_as2_actor_type_to_gs_actor_type))) { @@ -540,7 +540,7 @@ class ActivityPub extends Plugin * * @return null|Actor|mixed|Note got from URI */ - public static function getObjectByUri(string $resource, bool $try_online = true): mixed + public static function getObjectByUri(string $resource, bool $try_online = true, ?Actor $on_behalf_of = null): mixed { // Try known object $known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true); @@ -556,7 +556,7 @@ class ActivityPub extends Plugin // Try Actor try { - return Explorer::getOneFromUri($resource, try_online: false); + return Explorer::getOneFromUri($resource, try_online: false, on_behalf_of: $on_behalf_of); } catch (\Exception) { // Ignore, this is brute forcing, it's okay not to find } @@ -592,7 +592,7 @@ class ActivityPub extends Plugin throw new Exception("Remote resource {$resource} not found without online resources."); } - $response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]); + $response = Explorer::get($resource, $on_behalf_of); // If it was deleted if ($response->getStatusCode() == 410) { //$obj = Type::create('Tombstone', ['id' => $resource]); diff --git a/plugins/ActivityPub/Controller/Inbox.php b/plugins/ActivityPub/Controller/Inbox.php index 023e9f13c9..0c3fe58c44 100644 --- a/plugins/ActivityPub/Controller/Inbox.php +++ b/plugins/ActivityPub/Controller/Inbox.php @@ -32,12 +32,14 @@ declare(strict_types = 1); namespace Plugin\ActivityPub\Controller; +use ActivityPhp\Type\AbstractObject; use App\Core\Controller; use App\Core\DB; use function App\Core\I18n\_m; use App\Core\Log; use App\Core\Queue; use App\Core\Router; +use App\Entity\Actor; use App\Util\Common; use App\Util\Exception\ClientException; use Exception; @@ -95,10 +97,12 @@ class Inbox extends Controller return $error('Actor not found in the request.'); } + $to_actor = $this->deriveActivityTo($type); + try { $resource_parts = parse_url($type->get('actor')); if ($resource_parts['host'] !== Common::config('site', 'server')) { - $actor = DB::wrapInTransaction(fn () => Explorer::getOneFromUri($type->get('actor'))); + $actor = DB::wrapInTransaction(fn () => Explorer::getOneFromUri($type->get('actor'), try_online: true, on_behalf_of: $to_actor)); $ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()]); } else { throw new Exception('Only remote actors can use this endpoint.'); @@ -136,7 +140,7 @@ class Inbox extends Controller // If the signature fails verification the first time, update profile as it might have changed public key if ($verified !== 1) { try { - $res = Explorer::getRemoteActorActivity($ap_actor->getUri()); + $res = Explorer::getRemoteActorActivity($ap_actor->getUri(), $to_actor); if (\is_null($res)) { return $error('Invalid remote actor (null response).'); } @@ -169,4 +173,68 @@ class Inbox extends Controller return new TypeResponse($type, status: 202); } + + /** + * Poke at the given AbstractObject to find out who it is 'to'. + * Function will check through the 'to', 'cc', and 'object' fields + * of the given type (in that order) to check if if points to anyone + * on our instance. The first found candidate will be returned. + * + * @param AbstractObject $type + * + * @return Actor|null The discovered actor, if found. null otherwise. + * + * @throws Exception + */ + private function deriveActivityTo(AbstractObject $type): Actor|null + { + foreach (['to', 'cc'] as $field) { + foreach ((array) $type->get($field) as $uri) { + $actor = self::uriToMaybeLocalActor($uri); + if (!\is_null($actor)) { + return $actor; + } + } + } + + // if it's not to or cc anyone we have to dive deeper + if ($type->has('object')) { + // the 'object' field might just be a uri of one + // of our Actors, if this is a follow or block + $object = $type->get('object'); + if (\is_string($object)) { + $actor = self::uriToMaybeLocalActor($object); + if (!\is_null($actor)) { + return $actor; + } + } else if ($object instanceof AbstractObject) { + // if the inner object is also a Type, repeat the process + return $this->deriveActivityTo($object); + } + } + + return null; + } + + /** + * Get local actor that owns or corresponds to given uri. + * + * @param string $uri + * + * @return Actor|null + */ + private static function uriToMaybeLocalActor(string $uri): Actor|null + { + $parsed = parse_url($uri); + // check if this uri belongs to us + if ($parsed['host'] === Common::config('site', 'server')) { + // it is our uri so we should be able to get + // the actor without making any remote calls + $actor = Explorer::getLocalActorForPath($parsed['path']); + if (!\is_null($actor)) { + return $actor; + } + } + return null; + } } diff --git a/plugins/ActivityPub/Util/Explorer.php b/plugins/ActivityPub/Util/Explorer.php index 06a81fb8cc..a7fb13950f 100644 --- a/plugins/ActivityPub/Util/Explorer.php +++ b/plugins/ActivityPub/Util/Explorer.php @@ -37,6 +37,7 @@ use App\Core\HTTPClient; use App\Core\Log; use App\Entity\Actor; use App\Entity\LocalUser; +use App\Entity\Note; use App\Util\Common; use App\Util\Exception\NoSuchActorException; use App\Util\Nickname; @@ -49,6 +50,7 @@ use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; /** * ActivityPub's own Explorer @@ -65,17 +67,19 @@ class Explorer /** * Shortcut function to get a single profile from its URL. * - * @param bool $try_online whether to try online grabbing, defaults to true - * + * @param bool $try_online whether to try online grabbing, defaults to true + * @param Actor $on_behalf_of AP Actor on behalf of whom any remote lookups are to be performed, defaults to null. + * If null, outgoing GET request(s) will not be http signed. + * * @throws ClientExceptionInterface * @throws NoSuchActorException * @throws RedirectionExceptionInterface * @throws ServerExceptionInterface * @throws TransportExceptionInterface */ - public static function getOneFromUri(string $uri, bool $try_online = true): Actor + public static function getOneFromUri(string $uri, bool $try_online = true, ?Actor $on_behalf_of = null): Actor { - $actors = (new self())->lookup($uri, $try_online); + $actors = (new self())->lookup($uri, $try_online, $on_behalf_of); switch (\count($actors)) { case 1: return $actors[0]; @@ -91,8 +95,10 @@ class Explorer * This function cleans the $this->discovered_actor_profiles array * so that there is no erroneous data * - * @param string $uri User's url - * @param bool $try_online whether to try online grabbing, defaults to true + * @param string $uri User's url + * @param bool $try_online whether to try online grabbing, defaults to true + * @param Actor $on_behalf_of AP Actor on behalf of whom the lookup is being performed, defaults to null. + * If null, outgoing GET request(s) will not be http signed. * * @throws ClientExceptionInterface * @throws NoSuchActorException @@ -102,7 +108,7 @@ class Explorer * * @return array of Actor objects */ - public function lookup(string $uri, bool $try_online = true): array + public function lookup(string $uri, bool $try_online = true, ?Actor $on_behalf_of = null): array { if (\in_array($uri, ActivityPub::PUBLIC_TO)) { return []; @@ -111,7 +117,7 @@ class Explorer Log::debug('ActivityPub Explorer: Started now looking for ' . $uri); $this->discovered_actors = []; - return $this->_lookup($uri, $try_online); + return $this->_lookup($uri, $try_online, $on_behalf_of); } /** @@ -119,8 +125,10 @@ class Explorer * This is a recursive function that will accumulate the results on * $discovered_actor_profiles array * - * @param string $uri User's url - * @param bool $try_online whether to try online grabbing, defaults to true + * @param string $uri User's url + * @param bool $try_online whether to try online grabbing, defaults to true + * @param Actor $on_behalf_of Actor on behalf of whom the lookup is being performed, defaults to null. + * If null, outgoing GET request(s) will not be http signed. * * @throws ClientExceptionInterface * @throws NoSuchActorException @@ -130,13 +138,13 @@ class Explorer * * @return array of Actor objects */ - private function _lookup(string $uri, bool $try_online = true): array + private function _lookup(string $uri, bool $try_online = true, ?Actor $on_behalf_of = null): array { $grab_known = $this->grabKnownActor($uri); // First check if we already have it locally and, if so, return it. // If the known fetch fails and remote grab is required: store locally and return. - if (!$grab_known && (!$try_online || !$this->grabRemoteActor($uri))) { + if (!$grab_known && (!$try_online || !$this->grabRemoteActor($uri, $on_behalf_of))) { throw new NoSuchActorException('Actor not found.'); } @@ -158,32 +166,25 @@ class Explorer { Log::debug('ActivityPub Explorer: Searching locally for ' . $uri . ' offline.'); - // Try local - if (Common::isValidHttpUrl($uri)) { - // This means $uri is a valid url - $resource_parts = parse_url($uri); - // TODO: Use URLMatcher - if ($resource_parts['host'] === Common::config('site', 'server')) { - $str = $resource_parts['path']; - // actor_view_nickname - $renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m'; - // actor_view_id - $reuri = '/\/actor\/(\d+)\/?/m'; - if (preg_match_all($renick, $str, $matches, \PREG_SET_ORDER, 0) === 1) { - $this->discovered_actors[] = DB::findOneBy( - LocalUser::class, - ['nickname' => $matches[0][1]], - )->getActor(); - return true; - } elseif (preg_match_all($reuri, $str, $matches, \PREG_SET_ORDER, 0) === 1) { - $this->discovered_actors[] = Actor::getById((int) $matches[0][1]); - return true; - } + if (!Common::isValidHttpUrl($uri)) { + Log::debug('ActivityPub Explorer: URI ' . $uri . ' was not a valid http url.'); + return false; + } + + // Check if uri corresponds to local actor + $resource_parts = parse_url($uri); + if ($resource_parts['host'] === Common::config('site', 'server')) { + $actor = $this::getLocalActorForPath($resource_parts['path']); + if (!\is_null($actor)) { + Log::debug('ActivityPub Explorer: Found local ActivityPub Actor for ' . $uri); + $this->discovered_actors[] = $actor; + return true; + } else { + Log::debug('ActivityPub Explorer: Unable to find a known local ActivityPub Actor for ' . $uri); } } - // Try standard ActivityPub route - // Is this a known filthy little mudblood? + // URI isn't for a local actor, try to get by URI more generally $aprofile = DB::findOneBy(ActivitypubActor::class, ['uri' => $uri], return_null: true); if (!\is_null($aprofile)) { Log::debug('ActivityPub Explorer: Found a known ActivityPub Actor for ' . $uri); @@ -200,7 +201,9 @@ class Explorer * Get a remote user(s) profile(s) from its URL and joins it on * $this->discovered_actor_profiles * - * @param string $uri User's url + * @param string $uri User's url + * @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null. + * If null, outgoing GET request(s) will not be http signed. * * @throws ClientExceptionInterface * @throws NoSuchActorException @@ -210,10 +213,9 @@ class Explorer * * @return bool success state */ - private function grabRemoteActor(string $uri): bool + private function grabRemoteActor(string $uri, ?Actor $on_behalf_of = null): bool { - Log::debug('ActivityPub Explorer: Trying to grab a remote actor for ' . $uri); - $response = HTTPClient::get($uri, ['headers' => ACTIVITYPUB::HTTP_CLIENT_HEADERS]); + $response = $this->get($uri, $on_behalf_of); $res = json_decode($response->getContent(), true); if ($response->getStatusCode() == 410) { // If it was deleted return true; // Nothing to add. @@ -227,7 +229,7 @@ class Explorer if ($res['type'] === 'OrderedCollection') { // It's a potential collection of actors!!! Log::debug('ActivityPub Explorer: Found a collection of actors for ' . $uri); - $this->travelCollection($res['first']); + $this->travelCollection($res['first'], $on_behalf_of); return true; } else { try { @@ -249,15 +251,19 @@ class Explorer /** * Allows the Explorer to transverse a collection of persons. * + * @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null. + * If null, outgoing GET request(s) will not be http signed. + * @param string $uri Collection's url + * * @throws ClientExceptionInterface * @throws NoSuchActorException * @throws RedirectionExceptionInterface * @throws ServerExceptionInterface * @throws TransportExceptionInterface */ - private function travelCollection(string $uri): bool + private function travelCollection(string $uri, ?Actor $on_behalf_of = null): bool { - $response = HTTPClient::get($uri, ['headers' => ACTIVITYPUB::HTTP_CLIENT_HEADERS]); + $response = $this->get($uri, $on_behalf_of); $res = json_decode($response->getContent(), true); if (!isset($res['orderedItems'])) { @@ -266,22 +272,47 @@ class Explorer // Accumulate findings foreach ($res['orderedItems'] as $actor_uri) { - $this->_lookup($actor_uri); + $this->_lookup($actor_uri, true, $on_behalf_of); } // Go through entire collection if (!\is_null($res['next'])) { - $this->travelCollection($res['next']); + $this->travelCollection($res['next'], $on_behalf_of); } return true; } + /** + * Perform an http GET request to the given uri. Will be http-signed on behalf of given Actor, if provided. + * + * @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null. + * If null, outgoing GET request(s) will not be http signed. + * @param string $uri uri of remote resource, expected to return an Activity/Object of some kind. + * + * @return ResponseInterface The http response, for further processing. + */ + public static function get(string $uri, ?Actor $on_behalf_of = null): ResponseInterface + { + $headers = []; + if (!\is_null($on_behalf_of)) { + // sign the http GET request + $headers = HTTPSignature::sign($on_behalf_of, $uri, body: false, addlHeaders: [], method: 'get'); + } else { + // just do a bare request + $headers = ACTIVITYPUB::HTTP_CLIENT_HEADERS; + } + + return HTTPClient::get($uri, ['headers' => $headers]); + } + /** * Get a remote user array from its URL (this function is only used for * profile updating and shall not be used for anything else) * - * @param string $uri User's url + * @param string $uri User's url + * @param Actor $on_behalf_of Actor on behalf of whom http GET requests are to be made, defaults to null. + * If null, outgoing GET request(s) will not be http signed. * * @throws ClientExceptionInterface * @throws Exception @@ -292,9 +323,9 @@ class Explorer * @return null|string If it is able to fetch, false if it's gone * // Exceptions when network issues or unsupported Activity format */ - public static function getRemoteActorActivity(string $uri): string|null + public static function getRemoteActorActivity(string $uri, ?Actor $on_behalf_of = null): string|null { - $response = HTTPClient::get($uri, ['headers' => ACTIVITYPUB::HTTP_CLIENT_HEADERS]); + $response = Explorer::get($uri, $on_behalf_of); // If it was deleted if ($response->getStatusCode() == 410) { return null; @@ -303,4 +334,37 @@ class Explorer } return $response->getContent(); } + + /** + * Parse the given path and return the actor it corresponds to. + * + * @param String $path Path on *this instance*. Will be parsed with regular expressions. + * Something like `/actor/1` or `/object/note/1`. + * + * @return Actor|null The actor corresponding to/owning the given uri, null if not found. + */ + public static function getLocalActorForPath(string $path): Actor|null + { + // TODO: Use URLMatcher + + // actor_view_nickname + $renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/'; + if (preg_match_all($renick, $path, $matches, \PREG_SET_ORDER, 0) === 1) { + return DB::findOneBy(LocalUser::class, ['nickname' => $matches[0][1]])->getActor(); + } + + // actor_view_id + $reuri = '/\/actor\/(\d+)\/?/'; + if (preg_match_all($reuri, $path, $matches, \PREG_SET_ORDER, 0) === 1) { + return Actor::getById((int) $matches[0][1]); + } + + // note / page / article match + $renote = '/\/object\/(?:note|page|article)\/(\d+)\/?/'; + if (preg_match_all($renote, $path, $matches, \PREG_SET_ORDER, 0) === 1) { + return Note::getById((int) $matches[0][1])->getActor(); + } + + return null; + } } diff --git a/plugins/ActivityPub/Util/HTTPSignature.php b/plugins/ActivityPub/Util/HTTPSignature.php index c89a52e7c8..0e3967108e 100644 --- a/plugins/ActivityPub/Util/HTTPSignature.php +++ b/plugins/ActivityPub/Util/HTTPSignature.php @@ -47,13 +47,13 @@ class HTTPSignature * * @return array Headers to be used in request */ - public static function sign(Actor $user, string $url, string|bool $body = false, array $addlHeaders = []): array + public static function sign(Actor $user, string $url, string|bool $body = false, array $addlHeaders = [], string $method = 'post'): array { $digest = false; if ($body) { $digest = self::_digest($body); } - $headers = self::_headersToSign($url, $digest); + $headers = self::_headersToSign($url, $digest, $method); $headers = array_merge($headers, $addlHeaders); $stringToSign = self::_headersToSigningString($headers); $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); @@ -81,14 +81,23 @@ class HTTPSignature } /** + * Return a canonical array of http headers, ready to be signed. + * + * @param string $uri uri of destination + * @param string|bool $digest digest of the request body to add to the `Digest` header (optional). + * @param string $method http method (GET, POST, etc) that the request will use. + * This will be used in the `(request-target)` part of the signature. + * + * @return array Headers to be signed. + * * @throws Exception */ - protected static function _headersToSign(string $url, string|bool $digest = false): array + protected static function _headersToSign(string $url, string|bool $digest = false, string $method): array { $date = new DateTime('UTC'); $headers = [ - '(request-target)' => 'post ' . parse_url($url, \PHP_URL_PATH), + '(request-target)' => strtolower($method) . ' ' . parse_url($url, \PHP_URL_PATH), 'Date' => $date->format('D, d M Y H:i:s \G\M\T'), 'Host' => parse_url($url, \PHP_URL_HOST), 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, application/json',