CVE-2024-23832: Authentication Bypass by Spoofing (CWE-290)
value: Mastodon is a free, open-source social network server based on ActivityPub Mastodon allows configuration of LDAP for authentication. Due to insufficient origin validation in all Mastodon, attackers can impersonate and take over any remote account. Every Mastodon version prior to 3.5.17 is vulnerable, as well as 4.0.x versions prior to 4.0.13, 4.1.x version prior to 4.1.13, and 4.2.x versions prior to 4.2.5.
CWE description: This attack-focused weakness is caused by incorrectly implemented authentication schemes that are subject to spoofing attacks.
CVSS v3 base score: 9.8
Published: 2024-02-01T17:15Z
Repository: https://github.com/mastodon/mastodon
Language: Ruby
Files changed: app/controllers/concerns/signature_verification.rb, app/helpers/jsonld_helper.rb, app/lib/activitypub/activity.rb, app/lib/activitypub/linked_data_signature.rb, app/services/activitypub/fetch_remote_account_service.rb, app/services/activitypub/fetch_remote_actor_service.rb, app/services/activitypub/fetch_remote_key_service.rb, app/services/activitypub/fetch_remote_status_service.rb, app/services/activitypub/process_account_service.rb, app/services/fetch_resource_service.rb, lib/mastodon/version.rb, spec/lib/activitypub/linked_data_signature_spec.rb, spec/services/activitypub/fetch_remote_account_service_spec.rb, spec/services/activitypub/fetch_remote_actor_service_spec.rb, spec/services/activitypub/fetch_remote_key_service_spec.rb, spec/services/fetch_resource_service_spec.rb, spec/services/resolve_url_service_spec.rb
Commit date: 2024-02-01 15:56:46 +0100
Commit message: Merge pull request from GHSA-3fjr-858r-92rw

* Fix insufficient origin validation

* Bump version to 4.3.0-alpha.1
Diff: diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 35391e64c44..92f1eb5a168 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -264,11 +264,11 @@ module SignatureVerification
 
     if key_id.start_with?('acct:')
       stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
     elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
       account   = ActivityPub::TagManager.instance.uri_to_actor(key_id)
-      account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }
+      account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) }
       account
     end
   rescue Mastodon::PrivateNetworkAddressError => e
     raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
   rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index b3d0d032c4d..cc05b7a4034 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -153,12 +153,12 @@ module JsonLdHelper
         value == compacted_value
       end
     end
   end
 
-  def fetch_resource(uri, id, on_behalf_of = nil, request_options: {})
-    unless id
+  def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {})
+    unless id_is_known
       json = fetch_resource_without_id_validation(uri, on_behalf_of)
 
       return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
 
       uri = json['id']
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 51384ef9846..322f3e27adb 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -152,11 +152,11 @@ class ActivityPub::Activity
 
   def fetch_remote_original_status
     if object_uri.start_with?('http')
       return if ActivityPub::TagManager.instance.local_uri?(object_uri)
 
-      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
     elsif @object['url'].present?
       ::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
     end
   end
 
diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb
index faea63e8f12..9459fdd8b76 100644
--- a/app/lib/activitypub/linked_data_signature.rb
+++ b/app/lib/activitypub/linked_data_signature.rb
@@ -17,11 +17,11 @@ class ActivityPub::LinkedDataSignature
     signature   = @json['signature']['signatureValue']
 
     return unless type == 'RsaSignature2017'
 
     creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
-    creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank?
+    creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank?
 
     return if creator.nil?
 
     options_hash   = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
     document_hash  = hash(@json.without('signature'))
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index 567dd8a14ab..7b083d889b2 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
   # Does a WebFinger roundtrip on each call, unless `only_key` is true
-  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
+  def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
     actor = super
     return actor if actor.nil? || actor.is_a?(Account)
 
     Rails.logger.debug { "Fetching account #{uri} failed: Expected Account, got #{actor.class.name}" }
     raise Error, "Expected Account, got #{actor.class.name}" unless suppress_errors
diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb
index 8df8c758766..86a134bb4ed 100644
--- a/app/services/activitypub/fetch_remote_actor_service.rb
+++ b/app/services/activitypub/fetch_remote_actor_service.rb
@@ -8,19 +8,19 @@ class ActivityPub::FetchRemoteActorService < BaseService
   class Error < StandardError; end
 
   SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
 
   # Does a WebFinger roundtrip on each call, unless `only_key` is true
-  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
+  def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
     return if domain_not_allowed?(uri)
     return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)
 
     @json = begin
       if prefetched_body.nil?
-        fetch_resource(uri, id)
+        fetch_resource(uri, true)
       else
-        body_to_json(prefetched_body, compare_id: id ? uri : nil)
+        body_to_json(prefetched_body, compare_id: uri)
       end
     rescue Oj::ParseError
       raise Error, "Error parsing JSON-LD document #{uri}"
     end
 
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
index 8eb97c1e66d..e96b5ad3bb0 100644
--- a/app/services/activitypub/fetch_remote_key_service.rb
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -4,27 +4,14 @@ class ActivityPub::FetchRemoteKeyService < BaseService
   include JsonLdHelper
 
   class Error < StandardError; end
 
   # Returns actor that owns the key
-  def call(uri, id: true, prefetched_body: nil, suppress_errors: true)
+  def call(uri, suppress_errors: true)
     raise Error, 'No key URI given' if uri.blank?
 
-    if prefetched_body.nil?
-      if id
-        @json = fetch_resource_without_id_validation(uri)
-        if actor_type?
-          @json = fetch_resource(@json['id'], true)
-        elsif uri != @json['id']
-          raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}"
-        end
-      else
-        @json = fetch_resource(uri, id)
-      end
-    else
-      @json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
-    end
+    @json = fetch_resource(uri, false)
 
     raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
     raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json)
     raise Error, "Unexpected object type for key #{uri}" unless expected_type?
     return find_actor(@json['id'], @json, suppress_errors) if actor_type?
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index e3a9b60b567..6f8882378f3 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -6,18 +6,18 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   include Redisable
 
   DISCOVERIES_PER_REQUEST = 1000
 
   # Should be called when uri has already been checked for locality
-  def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
+  def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
     return if domain_not_allowed?(uri)
 
     @request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
     @json = if prefetched_body.nil?
-              fetch_resource(uri, id, on_behalf_of)
+              fetch_resource(uri, true, on_behalf_of)
             else
-              body_to_json(prefetched_body, compare_id: id ? uri : nil)
+              body_to_json(prefetched_body, compare_id: uri)
             end
 
     return unless supported_context?
 
     actor_uri     = nil
@@ -63,11 +63,11 @@ class ActivityPub::FetchRemoteStatusService < BaseService
     Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
   end
 
   def account_from_uri(uri)
     actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
-    actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true, request_id: @request_id) if actor.nil? || actor.possibly_stale?
+    actor = ActivityPub::FetchRemoteAccountService.new.call(uri, request_id: @request_id) if actor.nil? || actor.possibly_stale?
     actor
   end
 
   def supported_context?
     super(@json)
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 8fc0989a3f7..9e787ace508 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -275,11 +275,11 @@ class ActivityPub::ProcessAccountService < BaseService
     @collections[type] = [nil, nil]
   end
 
   def moved_account
     account   = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
-    account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true, request_id: @options[:request_id])
+    account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], break_on_redirect: true, request_id: @options[:request_id])
     account
   end
 
   def skip_download?
     @account.suspended? || domain_block&.reject_media?
diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb
index a3406e5a579..71c6cca790c 100644
--- a/app/services/fetch_resource_service.rb
+++ b/app/services/fetch_resource_service.rb
@@ -46,11 +46,19 @@ class FetchResourceService < BaseService
 
     if ['application/activity+json', 'application/ld+json'].include?(response.mime_type)
       body = response.body_with_limit
       json = body_to_json(body)
 
-      [json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
+      return unless supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
+
+      if json['id'] != @url
+        return if terminal
+
+        return process(json['id'], terminal: true)
+      end
+
+      [@url, { prefetched_body: body }]
     elsif !terminal
       link_header = response['Link'] && parse_link_header(response)
 
       if link_header&.find_link(%w(rel alternate))
         process_link_headers(link_header)
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 587e89303bd..dd7c84207e0 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -15,11 +15,11 @@ module Mastodon
     def patch
       0
     end
 
     def default_prerelease
-      'alpha.0'
+      'alpha.1'
     end
 
     def prerelease
       ENV['MASTODON_VERSION_PRERELEASE'].presence || default_prerelease
     end
diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb
index 97268eea6d0..1af45673c04 100644
--- a/spec/lib/activitypub/linked_data_signature_spec.rb
+++ b/spec/lib/activitypub/linked_data_signature_spec.rb
@@ -54,19 +54,19 @@ RSpec.describe ActivityPub::LinkedDataSignature do
         old_key = sender.public_key
         sender.update!(private_key: '', public_key: '')
 
         allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
 
-        allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do
+        allow(service_stub).to receive(:call).with('http://example.com/alice') do
           sender.update!(public_key: old_key)
           sender
         end
       end
 
       it 'fetches key and returns creator' do
         expect(subject.verify_actor!).to eq sender
-        expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once
+        expect(service_stub).to have_received(:call).with('http://example.com/alice').once
       end
     end
 
     context 'when signature is missing' do
       let(:signature) { nil }
diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb
index 4015723f6df..e7f6bb8dd83 100644
--- a/spec/services/activitypub/fetch_remote_account_service_spec.rb
+++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb
@@ -16,11 +16,11 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
       inbox: 'http://example.com/alice/inbox',
     }
   end
 
   describe '#call' do
-    let(:account) { subject.call('https://example.com/alice', id: true) }
+    let(:account) { subject.call('https://example.com/alice') }
 
     shared_examples 'sets profile data' do
       it 'returns an account with expected details' do
         expect(account)
           .to be_an(Account)
diff --git a/spec/services/activitypub/fetch_remote_actor_service_spec.rb b/spec/services/activitypub/fetch_remote_actor_service_spec.rb
index 485ca81a11a..e622c7d4c30 100644
--- a/spec/services/activitypub/fetch_remote_actor_service_spec.rb
+++ b/spec/services/activitypub/fetch_remote_actor_service_spec.rb
@@ -16,11 +16,11 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
       inbox: 'http://example.com/alice/inbox',
     }
   end
 
   describe '#call' do
-    let(:account) { subject.call('https://example.com/alice', id: true) }
+    let(:account) { subject.call('https://example.com/alice') }
 
     shared_examples 'sets profile data' do
       it 'returns an account and sets attributes' do
         expect(account)
           .to be_an(Account)
diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb
index e210d20ec77..0b14da4f446 100644
--- a/spec/services/activitypub/fetch_remote_key_service_spec.rb
+++ b/spec/services/activitypub/fetch_remote_key_service_spec.rb
@@ -53,11 +53,11 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
     stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
     stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
   end
 
   describe '#call' do
-    let(:account) { subject.call(public_key_id, id: false) }
+    let(:account) { subject.call(public_key_id) }
 
     context 'when the key is a sub-object from the actor' do
       before do
         stub_request(:get, public_key_id).to_return(body: Oj.dump(actor))
       end
diff --git a/spec/services/fetch_resource_service_spec.rb b/spec/services/fetch_resource_service_spec.rb
index 0f1068471f8..78037a06ce4 100644
--- a/spec/services/fetch_resource_service_spec.rb
+++ b/spec/services/fetch_resource_service_spec.rb
@@ -55,11 +55,11 @@ RSpec.describe FetchResourceService, type: :service do
         { 'Content-Type' => content_type }
       end
 
       let(:json) do
         {
-          id: 1,
+          id: 'http://example.com/foo',
           '@context': ActivityPub::TagManager::CONTEXT,
           type: 'Note',
         }.to_json
       end
 
@@ -81,30 +81,30 @@ RSpec.describe FetchResourceService, type: :service do
 
       context 'when content type is activity+json' do
         let(:content_type) { 'application/activity+json; charset=utf-8' }
         let(:body) { json }
 
-        it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
       end
 
       context 'when content type is ld+json with profile' do
         let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }
         let(:body) { json }
 
-        it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
       end
 
       context 'when link header is present' do
         let(:headers) { { 'Link' => '<http://example.com/foo>; rel="alternate"; type="application/activity+json"' } }
 
-        it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
       end
 
       context 'when content type is text/html' do
         let(:content_type) { 'text/html' }
         let(:body) { '<html><head><link rel="alternate" href="http://example.com/foo" type="application/activity+json"/></head></html>' }
 
-        it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
+        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
       end
     end
   end
 end
diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb
index bcfb9dbfb0f..5270cc10dd8 100644
--- a/spec/services/resolve_url_service_spec.rb
+++ b/spec/services/resolve_url_service_spec.rb
@@ -137,10 +137,11 @@ describe ResolveURLService, type: :service do
 
       before do
         stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url })
         body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json
         stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
+        stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
       end
 
       it 'returns status by url' do
         expect(subject.call(url, on_behalf_of: account)).to eq(status)
       end

Vulnerable code:       account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }
  def fetch_resource(uri, id, on_behalf_of = nil, request_options: {})
    unless id
      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
    creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank?
  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
  def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
        fetch_resource(uri, id)
        body_to_json(prefetched_body, compare_id: id ? uri : nil)
  def call(uri, id: true, prefetched_body: nil, suppress_errors: true)
    if prefetched_body.nil?
      if id
        @json = fetch_resource_without_id_validation(uri)
        if actor_type?
          @json = fetch_resource(@json['id'], true)
        elsif uri != @json['id']
          raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}"
        end
      else
        @json = fetch_resource(uri, id)
      end
    else
      @json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
    end
  def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
              fetch_resource(uri, id, on_behalf_of)
              body_to_json(prefetched_body, compare_id: id ? uri : nil)
    actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true, request_id: @request_id) if actor.nil? || actor.possibly_stale?
    account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true, request_id: @options[:request_id])
      [json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
      'alpha.0'
        allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do
        expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once
    let(:account) { subject.call('https://example.com/alice', id: true) }
    let(:account) { subject.call('https://example.com/alice', id: true) }
    let(:account) { subject.call(public_key_id, id: false) }
          id: 1,
        it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
        it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
        it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
        it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
Fixed code:       account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) }
  def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {})
    unless id_is_known
      ActivityPub::FetchRemoteStatusService.new.call(object_uri, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
    creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank?
  def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
  def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
        fetch_resource(uri, true)
        body_to_json(prefetched_body, compare_id: uri)
  def call(uri, suppress_errors: true)
    @json = fetch_resource(uri, false)
  def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
              fetch_resource(uri, true, on_behalf_of)
              body_to_json(prefetched_body, compare_id: uri)
    actor = ActivityPub::FetchRemoteAccountService.new.call(uri, request_id: @request_id) if actor.nil? || actor.possibly_stale?
    account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], break_on_redirect: true, request_id: @options[:request_id])
      return unless supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))

      if json['id'] != @url
        return if terminal

        return process(json['id'], terminal: true)
      end

      [@url, { prefetched_body: body }]
      'alpha.1'
        allow(service_stub).to receive(:call).with('http://example.com/alice') do
        expect(service_stub).to have_received(:call).with('http://example.com/alice').once
    let(:account) { subject.call('https://example.com/alice') }
    let(:account) { subject.call('https://example.com/alice') }
    let(:account) { subject.call(public_key_id) }
          id: 'http://example.com/foo',
        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
        it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
        stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })