From efc0d237afc71681c0e56cf8910c1e0f9c6f56ff Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 30 Jul 2025 16:39:30 +0200 Subject: [PATCH] Fix synchronous recursive fetching of deeply-nested quoted posts (#35600) --- app/lib/activitypub/activity/create.rb | 4 ++-- .../activitypub/fetch_remote_status_service.rb | 5 +++-- app/services/activitypub/verify_quote_service.rb | 13 +++++++++---- lib/exceptions.rb | 1 + 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index f7c723757ef..3fc9269dd3a 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -206,8 +206,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @quote.save embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context']) - ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id]) - rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS + ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id], depth: @options[:depth]) + rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] }) end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index 7173746f2de..0473bb5939f 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -8,9 +8,10 @@ class ActivityPub::FetchRemoteStatusService < BaseService DISCOVERIES_PER_REQUEST = 1000 # Should be called when uri has already been checked for locality - def call(uri, 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, depth: nil) return if domain_not_allowed?(uri) + @depth = depth || 0 @request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}" @json = if prefetched_body.nil? fetch_status(uri, true, on_behalf_of) @@ -52,7 +53,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService return nil if discoveries > DISCOVERIES_PER_REQUEST end - ActivityPub::Activity.factory(activity_json, actor, request_id: @request_id).perform + ActivityPub::Activity.factory(activity_json, actor, request_id: @request_id, depth: @depth).perform end private diff --git a/app/services/activitypub/verify_quote_service.rb b/app/services/activitypub/verify_quote_service.rb index ad4dfbe310c..a83a3c1155a 100644 --- a/app/services/activitypub/verify_quote_service.rb +++ b/app/services/activitypub/verify_quote_service.rb @@ -3,9 +3,12 @@ class ActivityPub::VerifyQuoteService < BaseService include JsonLdHelper + MAX_SYNCHRONOUS_DEPTH = 2 + # Optionally fetch quoted post, and verify the quote is authorized - def call(quote, fetchable_quoted_uri: nil, prefetched_quoted_object: nil, prefetched_approval: nil, request_id: nil) + def call(quote, fetchable_quoted_uri: nil, prefetched_quoted_object: nil, prefetched_approval: nil, request_id: nil, depth: nil) @request_id = request_id + @depth = depth || 0 @quote = quote @fetching_error = nil @@ -72,10 +75,12 @@ class ActivityPub::VerifyQuoteService < BaseService return if uri.nil? || @quote.quoted_status.present? status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status) - status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id) + raise Mastodon::RecursionLimitExceededError if @depth > MAX_SYNCHRONOUS_DEPTH && status.nil? + + status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id, depth: @depth + 1) @quote.update(quoted_status: status) if status.present? - rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e + rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e @fetching_error = e end @@ -90,7 +95,7 @@ class ActivityPub::VerifyQuoteService < BaseService # It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id']) - status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id) + status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id, depth: @depth) if status.present? @quote.update(quoted_status: status) diff --git a/lib/exceptions.rb b/lib/exceptions.rb index c8c81983825..18a99ace2a4 100644 --- a/lib/exceptions.rb +++ b/lib/exceptions.rb @@ -14,6 +14,7 @@ module Mastodon class InvalidParameterError < Error; end class SignatureVerificationError < Error; end class MalformedHeaderError < Error; end + class RecursionLimitExceededError < Error; end class UnexpectedResponseError < Error attr_reader :response