diff --git a/.github/changelog/2369-from-description b/.github/changelog/2369-from-description new file mode 100644 index 000000000..72e3cacce --- /dev/null +++ b/.github/changelog/2369-from-description @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fix infinite recursion when storing remote actors with mentions in their bios diff --git a/includes/collection/class-remote-actors.php b/includes/collection/class-remote-actors.php index ad169dfee..e334c89e9 100644 --- a/includes/collection/class-remote-actors.php +++ b/includes/collection/class-remote-actors.php @@ -482,12 +482,45 @@ private static function prepare_custom_post_type( $actor ) { ); } + /* + * Temporarily remove mention/hashtag/link filters to prevent infinite recursion when + * storing remote actors with mentions/hashtags in their bios. + * + * PROBLEM: These filters are globally registered on 'init' for all to_json() calls, + * but they're designed for OUTGOING content (federation). When processing mentions in + * an actor's bio during storage, the Mention filter fetches the mentioned actor, which + * then processes mentions in THEIR bio, creating infinite recursion. + * + * SHORTCOMINGS: + * - Fragile: Easy to forget when adding new storage locations (e.g., Inbox storage). + * - Scattered: Same pattern would need to be repeated anywhere we store remote content. + * - Race conditions: If filters are re-added/removed elsewhere, this could break. + * - Not semantic: We're working around a design issue rather than fixing it. + * + * BETTER LONG-TERM SOLUTION: + * Distinguish between "incoming" (storage) and "outgoing" (federation) contexts: + * - INCOMING: Store received ActivityPub data as-is, don't process mentions/hashtags. + * (Remote_Actors::prepare_custom_post_type, Inbox storage) + * - OUTGOING: Process mentions/hashtags when serving our content to other servers. + * (Dispatcher, REST API controllers, Transformers) + */ + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 ); + + $actor_json = $actor->to_json(); + + // Re-add the filters. + \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 ); + \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 ); + \add_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 ); + return array( 'guid' => \esc_url_raw( $actor->get_id() ), 'post_title' => \wp_strip_all_tags( \wp_slash( $actor->get_name() ?? $actor->get_preferred_username() ) ), 'post_author' => 0, 'post_type' => self::POST_TYPE, - 'post_content' => \wp_slash( $actor->to_json() ), + 'post_content' => \wp_slash( $actor_json ), 'post_excerpt' => \wp_kses( \wp_slash( (string) $actor->get_summary() ), 'user_description' ), 'post_status' => 'publish', 'meta_input' => array( diff --git a/tests/phpunit/tests/includes/collection/class-test-remote-actors.php b/tests/phpunit/tests/includes/collection/class-test-remote-actors.php index 0ac84c23b..1328fc77d 100644 --- a/tests/phpunit/tests/includes/collection/class-test-remote-actors.php +++ b/tests/phpunit/tests/includes/collection/class-test-remote-actors.php @@ -8,6 +8,7 @@ namespace Activitypub\Tests\Collection; use Activitypub\Collection\Remote_Actors; +use Activitypub\Mention; /** * Class Test_Remote_Actors @@ -873,6 +874,199 @@ function ( $preempt, $parsed_args, $url ) { \wp_delete_post( $post_id4, true ); } + /** + * Test that saving a remote actor with a self-mention doesn't cause infinite recursion. + * + * @covers ::create + * @covers ::prepare_custom_post_type + */ + public function test_create_actor_with_self_mention_no_recursion() { + // Ensure the Mention filter is active to test for recursion. + Mention::init(); + + // Create an actor with a self-mention in their summary. + $actor = array( + 'id' => 'https://remote.example.com/actor/self-mention', + 'type' => 'Person', + 'url' => 'https://remote.example.com/actor/self-mention', + 'inbox' => 'https://remote.example.com/actor/self-mention/inbox', + 'name' => 'Self Mention User', + 'preferredUsername' => 'selfmention', + 'summary' => 'Hello, I am @selfmention@remote.example.com and I like to mention myself!', + 'endpoints' => array( + 'sharedInbox' => 'https://remote.example.com/inbox', + ), + ); + + // Mock webfinger to resolve the mention. + $webfinger_callback = function ( $preempt, $parsed_args, $url ) { + if ( strpos( $url, '.well-known/webfinger' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( + array( + 'subject' => 'acct:selfmention@remote.example.com', + 'links' => array( + array( + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => 'https://remote.example.com/actor/self-mention', + ), + ), + ) + ), + ); + } + + return $preempt; + }; + \add_filter( 'pre_http_request', $webfinger_callback, 10, 3 ); + + // Mock remote actor fetch to return the same actor (creating potential recursion). + $actor_fetch_callback = function ( $pre, $url_or_object ) use ( $actor ) { + if ( $url_or_object === $actor['id'] ) { + return $actor; + } + + return $pre; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $actor_fetch_callback, 10, 2 ); + + // This should not cause infinite recursion. + $post_id = Remote_Actors::create( $actor ); + + $this->assertIsInt( $post_id ); + $this->assertGreaterThan( 0, $post_id ); + + $post = \get_post( $post_id ); + $this->assertInstanceOf( '\WP_Post', $post ); + $this->assertEquals( 'https://remote.example.com/actor/self-mention', $post->guid ); + + // Verify the summary was stored correctly (without being processed for mentions). + $this->assertStringContainsString( '@selfmention@remote.example.com', $post->post_excerpt ); + + // Clean up - remove only the specific filters we added. + \remove_filter( 'pre_http_request', $webfinger_callback ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $actor_fetch_callback ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 ); + \wp_delete_post( $post_id, true ); + } + + /** + * Test that saving a remote actor with mentions of other actors doesn't cause recursion. + * + * @covers ::create + * @covers ::prepare_custom_post_type + */ + public function test_create_actor_with_cross_mentions_no_recursion() { + // Ensure the Mention filter is active to test for recursion. + Mention::init(); + + // Create two actors that mention each other in their bios. + $actor_a = array( + 'id' => 'https://remote.example.com/actor/alice-cross', + 'type' => 'Person', + 'url' => 'https://remote.example.com/actor/alice-cross', + 'inbox' => 'https://remote.example.com/actor/alice-cross/inbox', + 'name' => 'Alice', + 'preferredUsername' => 'alice', + 'summary' => 'Best friends with @bob@remote.example.com', + 'endpoints' => array( + 'sharedInbox' => 'https://remote.example.com/inbox', + ), + ); + + $actor_b = array( + 'id' => 'https://remote.example.com/actor/bob-cross', + 'type' => 'Person', + 'url' => 'https://remote.example.com/actor/bob-cross', + 'inbox' => 'https://remote.example.com/actor/bob-cross/inbox', + 'name' => 'Bob', + 'preferredUsername' => 'bob', + 'summary' => 'Best friends with @alice@remote.example.com', + 'endpoints' => array( + 'sharedInbox' => 'https://remote.example.com/inbox', + ), + ); + + // Mock webfinger to resolve the mentions. + $webfinger_callback = function ( $preempt, $parsed_args, $url ) { + if ( strpos( $url, '.well-known/webfinger' ) !== false ) { + if ( strpos( $url, 'bob@remote.example.com' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( + array( + 'subject' => 'acct:bob@remote.example.com', + 'links' => array( + array( + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => 'https://remote.example.com/actor/bob-cross', + ), + ), + ) + ), + ); + } elseif ( strpos( $url, 'alice@remote.example.com' ) !== false ) { + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( + array( + 'subject' => 'acct:alice@remote.example.com', + 'links' => array( + array( + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => 'https://remote.example.com/actor/alice-cross', + ), + ), + ) + ), + ); + } + } + + return $preempt; + }; + \add_filter( 'pre_http_request', $webfinger_callback, 10, 3 ); + + // Mock the remote fetch to return the cross-mentioned actors. + $actor_fetch_callback = function ( $pre, $url_or_object ) use ( $actor_a, $actor_b ) { + if ( $url_or_object === $actor_a['id'] ) { + return $actor_a; + } + if ( $url_or_object === $actor_b['id'] ) { + return $actor_b; + } + + return $pre; + }; + \add_filter( 'activitypub_pre_http_get_remote_object', $actor_fetch_callback, 10, 2 ); + + // This should not cause infinite recursion when creating both actors. + $post_id_a = Remote_Actors::create( $actor_a ); + $this->assertIsInt( $post_id_a ); + + $post_id_b = Remote_Actors::create( $actor_b ); + $this->assertIsInt( $post_id_b ); + + // Verify both were created successfully. + $this->assertGreaterThan( 0, $post_id_a ); + $this->assertGreaterThan( 0, $post_id_b ); + + // Clean up - remove only the specific filters we added. + \remove_filter( 'pre_http_request', $webfinger_callback ); + \remove_filter( 'activitypub_pre_http_get_remote_object', $actor_fetch_callback ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Mention', 'filter_activity_object' ), 99 ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Hashtag', 'filter_activity_object' ), 99 ); + \remove_filter( 'activitypub_activity_object_array', array( 'Activitypub\Link', 'filter_activity_object' ), 99 ); + \wp_delete_post( $post_id_a, true ); + \wp_delete_post( $post_id_b, true ); + } + /** * Pre get remote metadata by actor. *