From 4733ca9fb9c13ec9f636e297ac65fe102a5ac046 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Thu, 26 Nov 2020 00:39:01 -0700 Subject: [PATCH] Add shared inbox --- app/Http/Controllers/FederationController.php | 11 ++ app/Jobs/InboxPipeline/InboxWorker.php | 115 +++++++++++++++++- .../ActivityPub/ProfileTransformer.php | 3 + app/Util/ActivityPub/Helpers.php | 91 ++++++++------ app/Util/ActivityPub/Inbox.php | 97 +++++++-------- app/Util/Lexer/RestrictedNames.php | 1 + config/federation.php | 2 +- routes/api.php | 1 + 8 files changed, 231 insertions(+), 90 deletions(-) diff --git a/app/Http/Controllers/FederationController.php b/app/Http/Controllers/FederationController.php index b26bf1a1c..a2f5d5832 100644 --- a/app/Http/Controllers/FederationController.php +++ b/app/Http/Controllers/FederationController.php @@ -108,6 +108,17 @@ class FederationController extends Controller return; } + public function sharedInbox(Request $request) + { + abort_if(!config('federation.activitypub.enabled'), 404); + abort_if(!config('federation.activitypub.sharedInbox'), 404); + + $headers = $request->headers->all(); + $payload = $request->getContent(); + dispatch(new InboxWorker($headers, $payload))->onQueue('high'); + return; + } + public function userFollowing(Request $request, $username) { abort_if(!config('federation.activitypub.enabled'), 404); diff --git a/app/Jobs/InboxPipeline/InboxWorker.php b/app/Jobs/InboxPipeline/InboxWorker.php index 20962113c..386c5cad0 100644 --- a/app/Jobs/InboxPipeline/InboxWorker.php +++ b/app/Jobs/InboxPipeline/InboxWorker.php @@ -26,10 +26,9 @@ class InboxWorker implements ShouldQueue * * @return void */ - public function __construct($headers, $profile, $payload) + public function __construct($headers, $payload) { $this->headers = $headers; - $this->profile = $profile; $this->payload = $payload; } @@ -40,6 +39,116 @@ class InboxWorker implements ShouldQueue */ public function handle() { - (new Inbox($this->headers, $this->profile, $this->payload))->handle(); + $profile = null; + $headers = $this->headers; + $payload = json_decode($this->payload, true, 8); + + if(!isset($headers['signature']) || !isset($headers['date'])) { + return; + } + + if(empty($headers) || empty($payload)) { + return; + } + + if($this->verifySignature($headers, $payload) == true) { + (new Inbox($headers, $profile, $payload))->handle(); + return; + } else if($this->blindKeyRotation($headers, $payload) == true) { + (new Inbox($headers, $profile, $payload))->handle(); + return; + } else { + return; + } + } + + protected function verifySignature($headers, $payload) + { + $body = $this->payload; + $bodyDecoded = $payload; + $signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature']; + $date = is_array($headers['date']) ? $headers['date'][0] : $headers['date']; + if(!$signature) { + return; + } + if(!$date) { + return; + } + if(!now()->parse($date)->gt(now()->subDays(1)) || + !now()->parse($date)->lt(now()->addDays(1)) + ) { + return; + } + $signatureData = HttpSignature::parseSignatureHeader($signature); + $keyId = Helpers::validateUrl($signatureData['keyId']); + $id = Helpers::validateUrl($bodyDecoded['id']); + $keyDomain = parse_url($keyId, PHP_URL_HOST); + $idDomain = parse_url($id, PHP_URL_HOST); + if(isset($bodyDecoded['object']) + && is_array($bodyDecoded['object']) + && isset($bodyDecoded['object']['attributedTo']) + ) { + if(parse_url($bodyDecoded['object']['attributedTo'], PHP_URL_HOST) !== $keyDomain) { + return; + abort(400, 'Invalid request'); + } + } + if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) { + return; + abort(400, 'Invalid request'); + } + $actor = Profile::whereKeyId($keyId)->first(); + if(!$actor) { + $actorUrl = is_array($bodyDecoded['actor']) ? $bodyDecoded['actor'][0] : $bodyDecoded['actor']; + $actor = Helpers::profileFirstOrNew($actorUrl); + } + if(!$actor) { + return; + } + $pkey = openssl_pkey_get_public($actor->public_key); + $inboxPath = "/f/inbox"; + list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body); + if($verified == 1) { + return true; + } else { + return false; + } + } + + protected function blindKeyRotation($headers, $payload) + { + $signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature']; + $date = is_array($headers['date']) ? $headers['date'][0] : $headers['date']; + if(!$signature) { + return; + } + if(!$date) { + return; + } + if(!now()->parse($date)->gt(now()->subDays(1)) || + !now()->parse($date)->lt(now()->addDays(1)) + ) { + return; + } + $signatureData = HttpSignature::parseSignatureHeader($signature); + $keyId = Helpers::validateUrl($signatureData['keyId']); + $actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first(); + if(!$actor) { + return; + } + if(Helpers::validateUrl($actor->remote_url) == false) { + return; + } + $res = Zttp::timeout(5)->withHeaders([ + 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org', + ])->get($actor->remote_url); + $res = json_decode($res->body(), true, 8); + if($res['publicKey']['id'] !== $actor->key_id) { + return; + } + $actor->public_key = $res['publicKey']['publicKeyPem']; + $actor->save(); + return $this->verifySignature($headers, $payload); } } diff --git a/app/Transformer/ActivityPub/ProfileTransformer.php b/app/Transformer/ActivityPub/ProfileTransformer.php index dcfb0870d..cea87875e 100644 --- a/app/Transformer/ActivityPub/ProfileTransformer.php +++ b/app/Transformer/ActivityPub/ProfileTransformer.php @@ -44,6 +44,9 @@ class ProfileTransformer extends Fractal\TransformerAbstract 'mediaType' => 'image/jpeg', 'url' => $profile->avatarUrl(), ], + 'endpoints' => [ + 'sharedInbox' => config('app.url') . '/f/inbox' + ] ]; } } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index bd5bc95b1..94b5369c3 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -135,41 +135,49 @@ class Helpers { if(is_array($url)) { $url = $url[0]; } - - $localhosts = [ - '127.0.0.1', 'localhost', '::1' - ]; - if(mb_substr($url, 0, 8) !== 'https://') { - return false; - } + $hash = hash('sha256', $url); + $key = "helpers:url:valid:sha256-{$hash}"; + $ttl = now()->addMinutes(5); - $valid = filter_var($url, FILTER_VALIDATE_URL); + $valid = Cache::remember($key, $ttl, function() use($url) { + $localhosts = [ + '127.0.0.1', 'localhost', '::1' + ]; - if(!$valid) { - return false; - } - - $host = parse_url($valid, PHP_URL_HOST); - - if(count(dns_get_record($host, DNS_A | DNS_AAAA)) == 0) { - return false; - } - - if(config('costar.enabled') == true) { - if( - (config('costar.domain.block') != null && Str::contains($host, config('costar.domain.block')) == true) || - (config('costar.actor.block') != null && in_array($url, config('costar.actor.block')) == true) - ) { + if(mb_substr($url, 0, 8) !== 'https://') { return false; } - } - if(in_array($host, $localhosts)) { - return false; - } + $valid = filter_var($url, FILTER_VALIDATE_URL); - return $valid; + if(!$valid) { + return false; + } + + $host = parse_url($valid, PHP_URL_HOST); + + if(count(dns_get_record($host, DNS_A | DNS_AAAA)) == 0) { + return false; + } + + if(config('costar.enabled') == true) { + if( + (config('costar.domain.block') != null && Str::contains($host, config('costar.domain.block')) == true) || + (config('costar.actor.block') != null && in_array($url, config('costar.actor.block')) == true) + ) { + return false; + } + } + + if(in_array($host, $localhosts)) { + return false; + } + + return true; + }); + + return (bool) $valid; } public static function validateLocalUrl($url) @@ -194,19 +202,25 @@ class Helpers { ]; } - public static function fetchFromUrl($url) + public static function fetchFromUrl($url = false) { - $url = self::validateUrl($url); - if($url == false) { + if(self::validateUrl($url) == false) { return; } - $res = Zttp::withHeaders(self::zttpUserAgent())->get($url); - $res = json_decode($res->body(), true, 8); - if(json_last_error() == JSON_ERROR_NONE) { - return $res; - } else { - return false; - } + + $hash = hash('sha256', $url); + $key = "helpers:url:fetcher:sha256-{$hash}"; + $ttl = now()->addMinutes(5); + + return Cache::remember($key, $ttl, function() use($url) { + $res = Zttp::withoutVerifying()->withHeaders(self::zttpUserAgent())->get($url); + $res = json_decode($res->body(), true, 8); + if(json_last_error() == JSON_ERROR_NONE) { + return $res; + } else { + return false; + } + }); } public static function fetchProfileFromUrl($url) @@ -444,6 +458,7 @@ class Helpers { $profile->name = isset($res['name']) ? Purify::clean($res['name']) : 'user'; $profile->bio = isset($res['summary']) ? Purify::clean($res['summary']) : null; $profile->last_fetched_at = now(); + $profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) && Helpers::validateUrl($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null; $profile->save(); } } diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index da5c33877..c671f093c 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -323,7 +323,7 @@ class Inbox public function handleFollowActivity() { $actor = $this->actorFirstOrCreate($this->payload['actor']); - $target = $this->profile; + $target = $this->actorFirstOrCreate($this->payload['object']); if(!$actor || $actor->domain == null || $target->domain !== null) { return; } @@ -470,55 +470,56 @@ class Inbox $profile->statuses()->delete(); $profile->delete(); return; - } - $type = $this->payload['object']['type']; - $typeCheck = in_array($type, ['Person', 'Tombstone']); - if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) { - return; - } - if(parse_url($obj['id'], PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) { - return; - } - $id = $this->payload['object']['id']; - switch ($type) { - case 'Person': - $profile = Profile::whereRemoteUrl($actor)->first(); - if(!$profile || $profile->private_key != null) { - return; - } - Notification::whereActorId($profile->id)->delete(); - $profile->avatar()->delete(); - $profile->followers()->delete(); - $profile->following()->delete(); - $profile->likes()->delete(); - $profile->media()->delete(); - $profile->hashtags()->delete(); - $profile->statuses()->delete(); - $profile->delete(); + } else { + $type = $this->payload['object']['type']; + $typeCheck = in_array($type, ['Person', 'Tombstone']); + if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) { return; - break; - - case 'Tombstone': - $profile = Helpers::profileFetch($actor); - $status = Status::whereProfileId($profile->id) - ->whereUri($id) - ->orWhere('url', $id) - ->orWhere('object_url', $id) - ->first(); - if(!$status) { - return; - } - $status->directMessage()->delete(); - $status->media()->delete(); - $status->likes()->delete(); - $status->shares()->delete(); - $status->delete(); + } + if(parse_url($obj['id'], PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) { + return; + } + $id = $this->payload['object']['id']; + switch ($type) { + case 'Person': + $profile = Profile::whereRemoteUrl($actor)->first(); + if(!$profile || $profile->private_key != null) { + return; + } + Notification::whereActorId($profile->id)->delete(); + $profile->avatar()->delete(); + $profile->followers()->delete(); + $profile->following()->delete(); + $profile->likes()->delete(); + $profile->media()->delete(); + $profile->hashtags()->delete(); + $profile->statuses()->delete(); + $profile->delete(); return; - break; - - default: - return; - break; + break; + + case 'Tombstone': + $profile = Helpers::profileFetch($actor); + $status = Status::whereProfileId($profile->id) + ->whereUri($id) + ->orWhere('url', $id) + ->orWhere('object_url', $id) + ->first(); + if(!$status) { + return; + } + $status->directMessage()->delete(); + $status->media()->delete(); + $status->likes()->delete(); + $status->shares()->delete(); + $status->delete(); + return; + break; + + default: + return; + break; + } } } diff --git a/app/Util/Lexer/RestrictedNames.php b/app/Util/Lexer/RestrictedNames.php index 8f3f97d4c..33f91b81d 100644 --- a/app/Util/Lexer/RestrictedNames.php +++ b/app/Util/Lexer/RestrictedNames.php @@ -177,6 +177,7 @@ class RestrictedNames 'help-center_', 'help_center-', 'i', + 'inbox', 'img', 'imgs', 'image', diff --git a/config/federation.php b/config/federation.php index 6a34f38e2..db8352267 100644 --- a/config/federation.php +++ b/config/federation.php @@ -20,7 +20,7 @@ return [ 'remoteFollow' => env('AP_REMOTE_FOLLOW', false), 'delivery' => [ - 'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0), + 'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30.0), 'concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10), 'logger' => [ 'enabled' => env('AP_LOGGER_ENABLED', false), diff --git a/routes/api.php b/routes/api.php index 6d7974d62..fca027286 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; $middleware = ['auth:api','twofactor','validemail','localization', 'throttle:60,1']; +Route::post('/f/inbox', 'FederationController@sharedInbox'); Route::post('/users/{username}/inbox', 'FederationController@userInbox'); Route::group(['prefix' => 'api'], function() use($middleware) {