username = $username; $this->headers = $headers; $this->payload = $payload; } /** * Execute the job. * * @return void */ public function handle() { $username = $this->username; $headers = $this->headers; if (empty($headers) || empty($this->payload) || ! isset($headers['signature']) || ! isset($headers['date'])) { return null; } $payload = json_decode($this->payload, true, 8); $profile = Profile::whereNull('domain')->whereUsername($username)->first(); if (empty($profile) || empty($headers) || empty($payload)) { return null; } if ($profile->status != null) { return null; } if ($this->verifySignature($headers, $profile, $payload) == true) { if (isset($payload['id'])) { $lockKey = 'pf:ap:user-inbox:activity:'.hash('sha256', $payload['id']); if (! Cache::add($lockKey, 1, 3600)) { // Already processed after valid signature check return 1; } } if (isset($payload['type']) && in_array($payload['type'], ['Follow', 'Accept'])) { ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('follow'); } else { $onQueue = Lottery::odds(1, 12)->winner(fn () => 'high')->loser(fn () => 'inbox')->choose(); ActivityHandler::dispatch($headers, $profile, $payload)->onQueue($onQueue); } return null; } return null; } protected function verifySignature($headers, $profile, $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 false; } if (! $date) { return false; } if ( ! now()->parse($date)->gt(now()->subDays(1)) || ! now()->parse($date)->lt(now()->addDays(1)) ) { return false; } if (! isset($bodyDecoded['id'])) { return false; } $signatureData = HttpSignature::parseSignatureHeader($signature); if (! isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) { return false; } $keyId = Helpers::validateUrl($signatureData['keyId']); $id = Helpers::validateUrl($bodyDecoded['id']); $keyDomain = parse_url($keyId, PHP_URL_HOST); $idDomain = parse_url($id, PHP_URL_HOST); $claimedActor = Helpers::pluckval($bodyDecoded['actor'] ?? null); if (is_array($claimedActor)) { $claimedActor = $claimedActor['id'] ?? null; } if (! $claimedActor && self::actorOptionalFor($bodyDecoded)) { $claimedActor = $keyId ? strtok($keyId, '#') : null; } $actorDomain = parse_url((string) $claimedActor, PHP_URL_HOST); if ( isset($bodyDecoded['object']) && is_array($bodyDecoded['object']) && isset($bodyDecoded['object']['attributedTo']) ) { $attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']); if (is_array($attr)) { if (isset($attr['id'])) { $attr = $attr['id']; } else { $attr = ''; } } if (parse_url($attr, PHP_URL_HOST) !== $keyDomain) { return false; } } if ( ! $keyDomain || ! $idDomain || ! $actorDomain || $keyDomain !== $idDomain || $keyDomain !== $actorDomain ) { return false; } $actor = Profile::whereKeyId($keyId)->first(); if (! $actor) { $actor = Helpers::profileFirstOrNew($claimedActor); } if (! $actor) { return false; } // Rebind: the profile resolved by keyId must belong to the keyId host. // This rejects a poisoned or stale row whose remote_url host differs // from the request's keyId host, so a planted key_id -> attacker key // binding cannot authenticate. if (parse_url($actor->remote_url, PHP_URL_HOST) !== $keyDomain) { return false; } $pkey = openssl_pkey_get_public($actor->public_key); if (! $pkey) { return false; } $inboxPath = "/users/{$profile->username}/inbox"; [$verified, $headers] = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body); return $verified == 1; } public static function actorOptionalFor(array $payload): bool { return isset($payload['type']) && $payload['type'] === 'FeatureRequest'; } }