[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
*/
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]);

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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',