Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/2369-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fix infinite recursion when storing remote actors with mentions in their bios
35 changes: 34 additions & 1 deletion includes/collection/class-remote-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
194 changes: 194 additions & 0 deletions tests/phpunit/tests/includes/collection/class-test-remote-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Activitypub\Tests\Collection;

use Activitypub\Collection\Remote_Actors;
use Activitypub\Mention;

/**
* Class Test_Remote_Actors
Expand Down Expand Up @@ -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 @[email protected] 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:[email protected]',
'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( '@[email protected]', $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 @[email protected]',
'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 @[email protected]',
'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, '[email protected]' ) !== false ) {
return array(
'response' => array( 'code' => 200 ),
'body' => wp_json_encode(
array(
'subject' => 'acct:[email protected]',
'links' => array(
array(
'rel' => 'self',
'type' => 'application/activity+json',
'href' => 'https://remote.example.com/actor/bob-cross',
),
),
)
),
);
} elseif ( strpos( $url, '[email protected]' ) !== false ) {
return array(
'response' => array( 'code' => 200 ),
'body' => wp_json_encode(
array(
'subject' => 'acct:[email protected]',
'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.
*
Expand Down