[PLUGIN][ActivityPub] Sign outgoing GET requests on behalf of relevant actor

This commit is contained in:
tsmethurst 2022-10-20 14:23:58 +02:00 committed by Hugo Sales
parent 3b3ded5212
commit 2df30e2987
Signed by: someonewithpc
GPG Key ID: 7D0C7EAFC9D835A0
4 changed files with 199 additions and 58 deletions

View File

@ -296,11 +296,11 @@ class ActivityPub extends Plugin
* *
* @return bool true if imported, false otherwise * @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)) { if (Common::isValidHttpUrl($uri)) {
try { try {
$object = self::getObjectByUri($uri); $object = self::getObjectByUri($uri, try_online: true, on_behalf_of: $on_behalf_of);
if (!\is_null($object)) { if (!\is_null($object)) {
if ($object instanceof Type\AbstractObject) { if ($object instanceof Type\AbstractObject) {
if (\in_array($object->get('type'), array_keys(Model\Actor::$_as2_actor_type_to_gs_actor_type))) { 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 * @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 // Try known object
$known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true); $known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true);
@ -556,7 +556,7 @@ class ActivityPub extends Plugin
// Try Actor // Try Actor
try { try {
return Explorer::getOneFromUri($resource, try_online: false); return Explorer::getOneFromUri($resource, try_online: false, on_behalf_of: $on_behalf_of);
} catch (\Exception) { } catch (\Exception) {
// Ignore, this is brute forcing, it's okay not to find // 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."); 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 it was deleted
if ($response->getStatusCode() == 410) { if ($response->getStatusCode() == 410) {
//$obj = Type::create('Tombstone', ['id' => $resource]); //$obj = Type::create('Tombstone', ['id' => $resource]);

View File

@ -32,12 +32,14 @@ declare(strict_types = 1);
namespace Plugin\ActivityPub\Controller; namespace Plugin\ActivityPub\Controller;
use ActivityPhp\Type\AbstractObject;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB; use App\Core\DB;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Queue; use App\Core\Queue;
use App\Core\Router; use App\Core\Router;
use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use Exception; use Exception;
@ -95,10 +97,12 @@ class Inbox extends Controller
return $error('Actor not found in the request.'); return $error('Actor not found in the request.');
} }
$to_actor = $this->deriveActivityTo($type);
try { try {
$resource_parts = parse_url($type->get('actor')); $resource_parts = parse_url($type->get('actor'));
if ($resource_parts['host'] !== Common::config('site', 'server')) { 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()]); $ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()]);
} else { } else {
throw new Exception('Only remote actors can use this endpoint.'); 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 the signature fails verification the first time, update profile as it might have changed public key
if ($verified !== 1) { if ($verified !== 1) {
try { try {
$res = Explorer::getRemoteActorActivity($ap_actor->getUri()); $res = Explorer::getRemoteActorActivity($ap_actor->getUri(), $to_actor);
if (\is_null($res)) { if (\is_null($res)) {
return $error('Invalid remote actor (null response).'); return $error('Invalid remote actor (null response).');
} }
@ -169,4 +173,68 @@ class Inbox extends Controller
return new TypeResponse($type, status: 202); 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;
}
} }

View File

@ -37,6 +37,7 @@ use App\Core\HTTPClient;
use App\Core\Log; use App\Core\Log;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\NoSuchActorException; use App\Util\Exception\NoSuchActorException;
use App\Util\Nickname; 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\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/** /**
* ActivityPub's own Explorer * ActivityPub's own Explorer
@ -65,17 +67,19 @@ class Explorer
/** /**
* Shortcut function to get a single profile from its URL. * 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 ClientExceptionInterface
* @throws NoSuchActorException * @throws NoSuchActorException
* @throws RedirectionExceptionInterface * @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface * @throws ServerExceptionInterface
* @throws TransportExceptionInterface * @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)) { switch (\count($actors)) {
case 1: case 1:
return $actors[0]; return $actors[0];
@ -91,8 +95,10 @@ class Explorer
* This function cleans the $this->discovered_actor_profiles array * This function cleans the $this->discovered_actor_profiles array
* so that there is no erroneous data * so that there is no erroneous data
* *
* @param string $uri User's url * @param string $uri User's 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 the lookup is being performed, defaults to null.
* If null, outgoing GET request(s) will not be http signed.
* *
* @throws ClientExceptionInterface * @throws ClientExceptionInterface
* @throws NoSuchActorException * @throws NoSuchActorException
@ -102,7 +108,7 @@ class Explorer
* *
* @return array of Actor objects * @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)) { if (\in_array($uri, ActivityPub::PUBLIC_TO)) {
return []; return [];
@ -111,7 +117,7 @@ class Explorer
Log::debug('ActivityPub Explorer: Started now looking for ' . $uri); Log::debug('ActivityPub Explorer: Started now looking for ' . $uri);
$this->discovered_actors = []; $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 * This is a recursive function that will accumulate the results on
* $discovered_actor_profiles array * $discovered_actor_profiles array
* *
* @param string $uri User's url * @param string $uri User's 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 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 ClientExceptionInterface
* @throws NoSuchActorException * @throws NoSuchActorException
@ -130,13 +138,13 @@ class Explorer
* *
* @return array of Actor objects * @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); $grab_known = $this->grabKnownActor($uri);
// First check if we already have it locally and, if so, return it. // 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 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.'); throw new NoSuchActorException('Actor not found.');
} }
@ -158,32 +166,25 @@ class Explorer
{ {
Log::debug('ActivityPub Explorer: Searching locally for ' . $uri . ' offline.'); Log::debug('ActivityPub Explorer: Searching locally for ' . $uri . ' offline.');
// Try local if (!Common::isValidHttpUrl($uri)) {
if (Common::isValidHttpUrl($uri)) { Log::debug('ActivityPub Explorer: URI ' . $uri . ' was not a valid http url.');
// This means $uri is a valid url return false;
$resource_parts = parse_url($uri); }
// TODO: Use URLMatcher
if ($resource_parts['host'] === Common::config('site', 'server')) { // Check if uri corresponds to local actor
$str = $resource_parts['path']; $resource_parts = parse_url($uri);
// actor_view_nickname if ($resource_parts['host'] === Common::config('site', 'server')) {
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m'; $actor = $this::getLocalActorForPath($resource_parts['path']);
// actor_view_id if (!\is_null($actor)) {
$reuri = '/\/actor\/(\d+)\/?/m'; Log::debug('ActivityPub Explorer: Found local ActivityPub Actor for ' . $uri);
if (preg_match_all($renick, $str, $matches, \PREG_SET_ORDER, 0) === 1) { $this->discovered_actors[] = $actor;
$this->discovered_actors[] = DB::findOneBy( return true;
LocalUser::class, } else {
['nickname' => $matches[0][1]], Log::debug('ActivityPub Explorer: Unable to find a known local ActivityPub Actor for ' . $uri);
)->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;
}
} }
} }
// Try standard ActivityPub route // URI isn't for a local actor, try to get by URI more generally
// Is this a known filthy little mudblood?
$aprofile = DB::findOneBy(ActivitypubActor::class, ['uri' => $uri], return_null: true); $aprofile = DB::findOneBy(ActivitypubActor::class, ['uri' => $uri], return_null: true);
if (!\is_null($aprofile)) { if (!\is_null($aprofile)) {
Log::debug('ActivityPub Explorer: Found a known ActivityPub Actor for ' . $uri); 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 * Get a remote user(s) profile(s) from its URL and joins it on
* $this->discovered_actor_profiles * $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 ClientExceptionInterface
* @throws NoSuchActorException * @throws NoSuchActorException
@ -210,10 +213,9 @@ class Explorer
* *
* @return bool success state * @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 = $this->get($uri, $on_behalf_of);
$response = HTTPClient::get($uri, ['headers' => ACTIVITYPUB::HTTP_CLIENT_HEADERS]);
$res = json_decode($response->getContent(), true); $res = json_decode($response->getContent(), true);
if ($response->getStatusCode() == 410) { // If it was deleted if ($response->getStatusCode() == 410) { // If it was deleted
return true; // Nothing to add. return true; // Nothing to add.
@ -227,7 +229,7 @@ class Explorer
if ($res['type'] === 'OrderedCollection') { // It's a potential collection of actors!!! if ($res['type'] === 'OrderedCollection') { // It's a potential collection of actors!!!
Log::debug('ActivityPub Explorer: Found a collection of actors for ' . $uri); Log::debug('ActivityPub Explorer: Found a collection of actors for ' . $uri);
$this->travelCollection($res['first']); $this->travelCollection($res['first'], $on_behalf_of);
return true; return true;
} else { } else {
try { try {
@ -249,15 +251,19 @@ class Explorer
/** /**
* Allows the Explorer to transverse a collection of persons. * 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 ClientExceptionInterface
* @throws NoSuchActorException * @throws NoSuchActorException
* @throws RedirectionExceptionInterface * @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface * @throws ServerExceptionInterface
* @throws TransportExceptionInterface * @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); $res = json_decode($response->getContent(), true);
if (!isset($res['orderedItems'])) { if (!isset($res['orderedItems'])) {
@ -266,22 +272,47 @@ class Explorer
// Accumulate findings // Accumulate findings
foreach ($res['orderedItems'] as $actor_uri) { foreach ($res['orderedItems'] as $actor_uri) {
$this->_lookup($actor_uri); $this->_lookup($actor_uri, true, $on_behalf_of);
} }
// Go through entire collection // Go through entire collection
if (!\is_null($res['next'])) { if (!\is_null($res['next'])) {
$this->travelCollection($res['next']); $this->travelCollection($res['next'], $on_behalf_of);
} }
return true; 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 * Get a remote user array from its URL (this function is only used for
* profile updating and shall not be used for anything else) * 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 ClientExceptionInterface
* @throws Exception * @throws Exception
@ -292,9 +323,9 @@ class Explorer
* @return null|string If it is able to fetch, false if it's gone * @return null|string If it is able to fetch, false if it's gone
* // Exceptions when network issues or unsupported Activity format * // 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 it was deleted
if ($response->getStatusCode() == 410) { if ($response->getStatusCode() == 410) {
return null; return null;
@ -303,4 +334,37 @@ class Explorer
} }
return $response->getContent(); 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;
}
} }

View File

@ -47,13 +47,13 @@ class HTTPSignature
* *
* @return array Headers to be used in request * @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; $digest = false;
if ($body) { if ($body) {
$digest = self::_digest($body); $digest = self::_digest($body);
} }
$headers = self::_headersToSign($url, $digest); $headers = self::_headersToSign($url, $digest, $method);
$headers = array_merge($headers, $addlHeaders); $headers = array_merge($headers, $addlHeaders);
$stringToSign = self::_headersToSigningString($headers); $stringToSign = self::_headersToSigningString($headers);
$signedHeaders = implode(' ', array_map('strtolower', array_keys($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 * @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'); $date = new DateTime('UTC');
$headers = [ $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'), 'Date' => $date->format('D, d M Y H:i:s \G\M\T'),
'Host' => parse_url($url, \PHP_URL_HOST), 'Host' => parse_url($url, \PHP_URL_HOST),
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, application/json', 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, application/json',